├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── AppendStream.php ├── BufferStream.php ├── CachingStream.php ├── DroppingStream.php ├── Exception └── MalformedUriException.php ├── FnStream.php ├── Header.php ├── HttpFactory.php ├── InflateStream.php ├── LazyOpenStream.php ├── LimitStream.php ├── Message.php ├── MessageTrait.php ├── MimeType.php ├── MultipartStream.php ├── NoSeekStream.php ├── PumpStream.php ├── Query.php ├── Request.php ├── Response.php ├── Rfc7230.php ├── ServerRequest.php ├── Stream.php ├── StreamDecoratorTrait.php ├── StreamWrapper.php ├── UploadedFile.php ├── Uri.php ├── UriComparator.php ├── UriNormalizer.php ├── UriResolver.php └── Utils.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 2.7.1 - 2025-03-27 9 | 10 | ### Fixed 11 | 12 | - Fixed uppercase IPv6 addresses in URI 13 | 14 | ### Changed 15 | 16 | - Improve uploaded file error message 17 | 18 | ## 2.7.0 - 2024-07-18 19 | 20 | ### Added 21 | 22 | - Add `Utils::redactUserInfo()` method 23 | - Add ability to encode bools as ints in `Query::build` 24 | 25 | ## 2.6.3 - 2024-07-18 26 | 27 | ### Fixed 28 | 29 | - Make `StreamWrapper::stream_stat()` return `false` if inner stream's size is `null` 30 | 31 | ### Changed 32 | 33 | - PHP 8.4 support 34 | 35 | ## 2.6.2 - 2023-12-03 36 | 37 | ### Fixed 38 | 39 | - Fixed another issue with the fact that PHP transforms numeric strings in array keys to ints 40 | 41 | ### Changed 42 | 43 | - Updated links in docs to their canonical versions 44 | - Replaced `call_user_func*` with native calls 45 | 46 | ## 2.6.1 - 2023-08-27 47 | 48 | ### Fixed 49 | 50 | - Properly handle the fact that PHP transforms numeric strings in array keys to ints 51 | 52 | ## 2.6.0 - 2023-08-03 53 | 54 | ### Changed 55 | 56 | - Updated the mime type map to add some new entries, fix a couple of invalid entries, and remove an invalid entry 57 | - Fallback to `application/octet-stream` if we are unable to guess the content type for a multipart file upload 58 | 59 | ## 2.5.1 - 2023-08-03 60 | 61 | ### Fixed 62 | 63 | - Corrected mime type for `.acc` files to `audio/aac` 64 | 65 | ### Changed 66 | 67 | - PHP 8.3 support 68 | 69 | ## 2.5.0 - 2023-04-17 70 | 71 | ### Changed 72 | 73 | - Adjusted `psr/http-message` version constraint to `^1.1 || ^2.0` 74 | 75 | ## 2.4.5 - 2023-04-17 76 | 77 | ### Fixed 78 | 79 | - Prevent possible warnings on unset variables in `ServerRequest::normalizeNestedFileSpec` 80 | - Fixed `Message::bodySummary` when `preg_match` fails 81 | - Fixed header validation issue 82 | 83 | ## 2.4.4 - 2023-03-09 84 | 85 | ### Changed 86 | 87 | - Removed the need for `AllowDynamicProperties` in `LazyOpenStream` 88 | 89 | ## 2.4.3 - 2022-10-26 90 | 91 | ### Changed 92 | 93 | - Replaced `sha1(uniqid())` by `bin2hex(random_bytes(20))` 94 | 95 | ## 2.4.2 - 2022-10-25 96 | 97 | ### Fixed 98 | 99 | - Fixed erroneous behaviour when combining host and relative path 100 | 101 | ## 2.4.1 - 2022-08-28 102 | 103 | ### Fixed 104 | 105 | - Rewind body before reading in `Message::bodySummary` 106 | 107 | ## 2.4.0 - 2022-06-20 108 | 109 | ### Added 110 | 111 | - Added provisional PHP 8.2 support 112 | - Added `UriComparator::isCrossOrigin` method 113 | 114 | ## 2.3.0 - 2022-06-09 115 | 116 | ### Fixed 117 | 118 | - Added `Header::splitList` method 119 | - Added `Utils::tryGetContents` method 120 | - Improved `Stream::getContents` method 121 | - Updated mimetype mappings 122 | 123 | ## 2.2.2 - 2022-06-08 124 | 125 | ### Fixed 126 | 127 | - Fix `Message::parseRequestUri` for numeric headers 128 | - Re-wrap exceptions thrown in `fread` into runtime exceptions 129 | - Throw an exception when multipart options is misformatted 130 | 131 | ## 2.2.1 - 2022-03-20 132 | 133 | ### Fixed 134 | 135 | - Correct header value validation 136 | 137 | ## 2.2.0 - 2022-03-20 138 | 139 | ### Added 140 | 141 | - A more compressive list of mime types 142 | - Add JsonSerializable to Uri 143 | - Missing return types 144 | 145 | ### Fixed 146 | 147 | - Bug MultipartStream no `uri` metadata 148 | - Bug MultipartStream with filename for `data://` streams 149 | - Fixed new line handling in MultipartStream 150 | - Reduced RAM usage when copying streams 151 | - Updated parsing in `Header::normalize()` 152 | 153 | ## 2.1.1 - 2022-03-20 154 | 155 | ### Fixed 156 | 157 | - Validate header values properly 158 | 159 | ## 2.1.0 - 2021-10-06 160 | 161 | ### Changed 162 | 163 | - Attempting to create a `Uri` object from a malformed URI will no longer throw a generic 164 | `InvalidArgumentException`, but rather a `MalformedUriException`, which inherits from the former 165 | for backwards compatibility. Callers relying on the exception being thrown to detect invalid 166 | URIs should catch the new exception. 167 | 168 | ### Fixed 169 | 170 | - Return `null` in caching stream size if remote size is `null` 171 | 172 | ## 2.0.0 - 2021-06-30 173 | 174 | Identical to the RC release. 175 | 176 | ## 2.0.0@RC-1 - 2021-04-29 177 | 178 | ### Fixed 179 | 180 | - Handle possibly unset `url` in `stream_get_meta_data` 181 | 182 | ## 2.0.0@beta-1 - 2021-03-21 183 | 184 | ### Added 185 | 186 | - PSR-17 factories 187 | - Made classes final 188 | - PHP7 type hints 189 | 190 | ### Changed 191 | 192 | - When building a query string, booleans are represented as 1 and 0. 193 | 194 | ### Removed 195 | 196 | - PHP < 7.2 support 197 | - All functions in the `GuzzleHttp\Psr7` namespace 198 | 199 | ## 1.8.1 - 2021-03-21 200 | 201 | ### Fixed 202 | 203 | - Issue parsing IPv6 URLs 204 | - Issue modifying ServerRequest lost all its attributes 205 | 206 | ## 1.8.0 - 2021-03-21 207 | 208 | ### Added 209 | 210 | - Locale independent URL parsing 211 | - Most classes got a `@final` annotation to prepare for 2.0 212 | 213 | ### Fixed 214 | 215 | - Issue when creating stream from `php://input` and curl-ext is not installed 216 | - Broken `Utils::tryFopen()` on PHP 8 217 | 218 | ## 1.7.0 - 2020-09-30 219 | 220 | ### Added 221 | 222 | - Replaced functions by static methods 223 | 224 | ### Fixed 225 | 226 | - Converting a non-seekable stream to a string 227 | - Handle multiple Set-Cookie correctly 228 | - Ignore array keys in header values when merging 229 | - Allow multibyte characters to be parsed in `Message:bodySummary()` 230 | 231 | ### Changed 232 | 233 | - Restored partial HHVM 3 support 234 | 235 | 236 | ## [1.6.1] - 2019-07-02 237 | 238 | ### Fixed 239 | 240 | - Accept null and bool header values again 241 | 242 | 243 | ## [1.6.0] - 2019-06-30 244 | 245 | ### Added 246 | 247 | - Allowed version `^3.0` of `ralouphie/getallheaders` dependency (#244) 248 | - Added MIME type for WEBP image format (#246) 249 | - Added more validation of values according to PSR-7 and RFC standards, e.g. status code range (#250, #272) 250 | 251 | ### Changed 252 | 253 | - Tests don't pass with HHVM 4.0, so HHVM support got dropped. Other libraries like composer have done the same. (#262) 254 | - Accept port number 0 to be valid (#270) 255 | 256 | ### Fixed 257 | 258 | - Fixed subsequent reads from `php://input` in ServerRequest (#247) 259 | - Fixed readable/writable detection for certain stream modes (#248) 260 | - Fixed encoding of special characters in the `userInfo` component of an URI (#253) 261 | 262 | 263 | ## [1.5.2] - 2018-12-04 264 | 265 | ### Fixed 266 | 267 | - Check body size when getting the message summary 268 | 269 | 270 | ## [1.5.1] - 2018-12-04 271 | 272 | ### Fixed 273 | 274 | - Get the summary of a body only if it is readable 275 | 276 | 277 | ## [1.5.0] - 2018-12-03 278 | 279 | ### Added 280 | 281 | - Response first-line to response string exception (fixes #145) 282 | - A test for #129 behavior 283 | - `get_message_body_summary` function in order to get the message summary 284 | - `3gp` and `mkv` mime types 285 | 286 | ### Changed 287 | 288 | - Clarify exception message when stream is detached 289 | 290 | ### Deprecated 291 | 292 | - Deprecated parsing folded header lines as per RFC 7230 293 | 294 | ### Fixed 295 | 296 | - Fix `AppendStream::detach` to not close streams 297 | - `InflateStream` preserves `isSeekable` attribute of the underlying stream 298 | - `ServerRequest::getUriFromGlobals` to support URLs in query parameters 299 | 300 | 301 | Several other fixes and improvements. 302 | 303 | 304 | ## [1.4.2] - 2017-03-20 305 | 306 | ### Fixed 307 | 308 | - Reverted BC break to `Uri::resolve` and `Uri::removeDotSegments` by removing 309 | calls to `trigger_error` when deprecated methods are invoked. 310 | 311 | 312 | ## [1.4.1] - 2017-02-27 313 | 314 | ### Added 315 | 316 | - Rriggering of silenced deprecation warnings. 317 | 318 | ### Fixed 319 | 320 | - Reverted BC break by reintroducing behavior to automagically fix a URI with a 321 | relative path and an authority by adding a leading slash to the path. It's only 322 | deprecated now. 323 | 324 | 325 | ## [1.4.0] - 2017-02-21 326 | 327 | ### Added 328 | 329 | - Added common URI utility methods based on RFC 3986 (see documentation in the readme): 330 | - `Uri::isDefaultPort` 331 | - `Uri::isAbsolute` 332 | - `Uri::isNetworkPathReference` 333 | - `Uri::isAbsolutePathReference` 334 | - `Uri::isRelativePathReference` 335 | - `Uri::isSameDocumentReference` 336 | - `Uri::composeComponents` 337 | - `UriNormalizer::normalize` 338 | - `UriNormalizer::isEquivalent` 339 | - `UriResolver::relativize` 340 | 341 | ### Changed 342 | 343 | - Ensure `ServerRequest::getUriFromGlobals` returns a URI in absolute form. 344 | - Allow `parse_response` to parse a response without delimiting space and reason. 345 | - Ensure each URI modification results in a valid URI according to PSR-7 discussions. 346 | Invalid modifications will throw an exception instead of returning a wrong URI or 347 | doing some magic. 348 | - `(new Uri)->withPath('foo')->withHost('example.com')` will throw an exception 349 | because the path of a URI with an authority must start with a slash "/" or be empty 350 | - `(new Uri())->withScheme('http')` will return `'http://localhost'` 351 | 352 | ### Deprecated 353 | 354 | - `Uri::resolve` in favor of `UriResolver::resolve` 355 | - `Uri::removeDotSegments` in favor of `UriResolver::removeDotSegments` 356 | 357 | ### Fixed 358 | 359 | - `Stream::read` when length parameter <= 0. 360 | - `copy_to_stream` reads bytes in chunks instead of `maxLen` into memory. 361 | - `ServerRequest::getUriFromGlobals` when `Host` header contains port. 362 | - Compatibility of URIs with `file` scheme and empty host. 363 | 364 | 365 | ## [1.3.1] - 2016-06-25 366 | 367 | ### Fixed 368 | 369 | - `Uri::__toString` for network path references, e.g. `//example.org`. 370 | - Missing lowercase normalization for host. 371 | - Handling of URI components in case they are `'0'` in a lot of places, 372 | e.g. as a user info password. 373 | - `Uri::withAddedHeader` to correctly merge headers with different case. 374 | - Trimming of header values in `Uri::withAddedHeader`. Header values may 375 | be surrounded by whitespace which should be ignored according to RFC 7230 376 | Section 3.2.4. This does not apply to header names. 377 | - `Uri::withAddedHeader` with an array of header values. 378 | - `Uri::resolve` when base path has no slash and handling of fragment. 379 | - Handling of encoding in `Uri::with(out)QueryValue` so one can pass the 380 | key/value both in encoded as well as decoded form to those methods. This is 381 | consistent with withPath, withQuery etc. 382 | - `ServerRequest::withoutAttribute` when attribute value is null. 383 | 384 | 385 | ## [1.3.0] - 2016-04-13 386 | 387 | ### Added 388 | 389 | - Remaining interfaces needed for full PSR7 compatibility 390 | (ServerRequestInterface, UploadedFileInterface, etc.). 391 | - Support for stream_for from scalars. 392 | 393 | ### Changed 394 | 395 | - Can now extend Uri. 396 | 397 | ### Fixed 398 | - A bug in validating request methods by making it more permissive. 399 | 400 | 401 | ## [1.2.3] - 2016-02-18 402 | 403 | ### Fixed 404 | 405 | - Support in `GuzzleHttp\Psr7\CachingStream` for seeking forward on remote 406 | streams, which can sometimes return fewer bytes than requested with `fread`. 407 | - Handling of gzipped responses with FNAME headers. 408 | 409 | 410 | ## [1.2.2] - 2016-01-22 411 | 412 | ### Added 413 | 414 | - Support for URIs without any authority. 415 | - Support for HTTP 451 'Unavailable For Legal Reasons.' 416 | - Support for using '0' as a filename. 417 | - Support for including non-standard ports in Host headers. 418 | 419 | 420 | ## [1.2.1] - 2015-11-02 421 | 422 | ### Changes 423 | 424 | - Now supporting negative offsets when seeking to SEEK_END. 425 | 426 | 427 | ## [1.2.0] - 2015-08-15 428 | 429 | ### Changed 430 | 431 | - Body as `"0"` is now properly added to a response. 432 | - Now allowing forward seeking in CachingStream. 433 | - Now properly parsing HTTP requests that contain proxy targets in 434 | `parse_request`. 435 | - functions.php is now conditionally required. 436 | - user-info is no longer dropped when resolving URIs. 437 | 438 | 439 | ## [1.1.0] - 2015-06-24 440 | 441 | ### Changed 442 | 443 | - URIs can now be relative. 444 | - `multipart/form-data` headers are now overridden case-insensitively. 445 | - URI paths no longer encode the following characters because they are allowed 446 | in URIs: "(", ")", "*", "!", "'" 447 | - A port is no longer added to a URI when the scheme is missing and no port is 448 | present. 449 | 450 | 451 | ## 1.0.0 - 2015-05-19 452 | 453 | Initial release. 454 | 455 | Currently unsupported: 456 | 457 | - `Psr\Http\Message\ServerRequestInterface` 458 | - `Psr\Http\Message\UploadedFileInterface` 459 | 460 | 461 | 462 | [1.6.0]: https://github.com/guzzle/psr7/compare/1.5.2...1.6.0 463 | [1.5.2]: https://github.com/guzzle/psr7/compare/1.5.1...1.5.2 464 | [1.5.1]: https://github.com/guzzle/psr7/compare/1.5.0...1.5.1 465 | [1.5.0]: https://github.com/guzzle/psr7/compare/1.4.2...1.5.0 466 | [1.4.2]: https://github.com/guzzle/psr7/compare/1.4.1...1.4.2 467 | [1.4.1]: https://github.com/guzzle/psr7/compare/1.4.0...1.4.1 468 | [1.4.0]: https://github.com/guzzle/psr7/compare/1.3.1...1.4.0 469 | [1.3.1]: https://github.com/guzzle/psr7/compare/1.3.0...1.3.1 470 | [1.3.0]: https://github.com/guzzle/psr7/compare/1.2.3...1.3.0 471 | [1.2.3]: https://github.com/guzzle/psr7/compare/1.2.2...1.2.3 472 | [1.2.2]: https://github.com/guzzle/psr7/compare/1.2.1...1.2.2 473 | [1.2.1]: https://github.com/guzzle/psr7/compare/1.2.0...1.2.1 474 | [1.2.0]: https://github.com/guzzle/psr7/compare/1.1.0...1.2.0 475 | [1.1.0]: https://github.com/guzzle/psr7/compare/1.0.0...1.1.0 476 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michael Dowling 4 | Copyright (c) 2015 Márk Sági-Kazár 5 | Copyright (c) 2015 Graham Campbell 6 | Copyright (c) 2016 Tobias Schultze 7 | Copyright (c) 2016 George Mponos 8 | Copyright (c) 2018 Tobias Nyholm 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in 18 | all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | THE SOFTWARE. 27 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guzzlehttp/psr7", 3 | "description": "PSR-7 message implementation that also provides common utility methods", 4 | "keywords": [ 5 | "request", 6 | "response", 7 | "message", 8 | "stream", 9 | "http", 10 | "uri", 11 | "url", 12 | "psr-7" 13 | ], 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Graham Campbell", 18 | "email": "hello@gjcampbell.co.uk", 19 | "homepage": "https://github.com/GrahamCampbell" 20 | }, 21 | { 22 | "name": "Michael Dowling", 23 | "email": "mtdowling@gmail.com", 24 | "homepage": "https://github.com/mtdowling" 25 | }, 26 | { 27 | "name": "George Mponos", 28 | "email": "gmponos@gmail.com", 29 | "homepage": "https://github.com/gmponos" 30 | }, 31 | { 32 | "name": "Tobias Nyholm", 33 | "email": "tobias.nyholm@gmail.com", 34 | "homepage": "https://github.com/Nyholm" 35 | }, 36 | { 37 | "name": "Márk Sági-Kazár", 38 | "email": "mark.sagikazar@gmail.com", 39 | "homepage": "https://github.com/sagikazarmark" 40 | }, 41 | { 42 | "name": "Tobias Schultze", 43 | "email": "webmaster@tubo-world.de", 44 | "homepage": "https://github.com/Tobion" 45 | }, 46 | { 47 | "name": "Márk Sági-Kazár", 48 | "email": "mark.sagikazar@gmail.com", 49 | "homepage": "https://sagikazarmark.hu" 50 | } 51 | ], 52 | "require": { 53 | "php": "^7.2.5 || ^8.0", 54 | "psr/http-factory": "^1.0", 55 | "psr/http-message": "^1.1 || ^2.0", 56 | "ralouphie/getallheaders": "^3.0" 57 | }, 58 | "provide": { 59 | "psr/http-factory-implementation": "1.0", 60 | "psr/http-message-implementation": "1.0" 61 | }, 62 | "require-dev": { 63 | "bamarni/composer-bin-plugin": "^1.8.2", 64 | "http-interop/http-factory-tests": "0.9.0", 65 | "phpunit/phpunit": "^8.5.39 || ^9.6.20" 66 | }, 67 | "suggest": { 68 | "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" 69 | }, 70 | "autoload": { 71 | "psr-4": { 72 | "GuzzleHttp\\Psr7\\": "src/" 73 | } 74 | }, 75 | "autoload-dev": { 76 | "psr-4": { 77 | "GuzzleHttp\\Tests\\Psr7\\": "tests/" 78 | } 79 | }, 80 | "extra": { 81 | "bamarni-bin": { 82 | "bin-links": true, 83 | "forward-command": false 84 | } 85 | }, 86 | "config": { 87 | "allow-plugins": { 88 | "bamarni/composer-bin-plugin": true 89 | }, 90 | "preferred-install": "dist", 91 | "sort-packages": true 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/AppendStream.php: -------------------------------------------------------------------------------- 1 | addStream($stream); 36 | } 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | try { 42 | $this->rewind(); 43 | 44 | return $this->getContents(); 45 | } catch (\Throwable $e) { 46 | if (\PHP_VERSION_ID >= 70400) { 47 | throw $e; 48 | } 49 | trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); 50 | 51 | return ''; 52 | } 53 | } 54 | 55 | /** 56 | * Add a stream to the AppendStream 57 | * 58 | * @param StreamInterface $stream Stream to append. Must be readable. 59 | * 60 | * @throws \InvalidArgumentException if the stream is not readable 61 | */ 62 | public function addStream(StreamInterface $stream): void 63 | { 64 | if (!$stream->isReadable()) { 65 | throw new \InvalidArgumentException('Each stream must be readable'); 66 | } 67 | 68 | // The stream is only seekable if all streams are seekable 69 | if (!$stream->isSeekable()) { 70 | $this->seekable = false; 71 | } 72 | 73 | $this->streams[] = $stream; 74 | } 75 | 76 | public function getContents(): string 77 | { 78 | return Utils::copyToString($this); 79 | } 80 | 81 | /** 82 | * Closes each attached stream. 83 | */ 84 | public function close(): void 85 | { 86 | $this->pos = $this->current = 0; 87 | $this->seekable = true; 88 | 89 | foreach ($this->streams as $stream) { 90 | $stream->close(); 91 | } 92 | 93 | $this->streams = []; 94 | } 95 | 96 | /** 97 | * Detaches each attached stream. 98 | * 99 | * Returns null as it's not clear which underlying stream resource to return. 100 | */ 101 | public function detach() 102 | { 103 | $this->pos = $this->current = 0; 104 | $this->seekable = true; 105 | 106 | foreach ($this->streams as $stream) { 107 | $stream->detach(); 108 | } 109 | 110 | $this->streams = []; 111 | 112 | return null; 113 | } 114 | 115 | public function tell(): int 116 | { 117 | return $this->pos; 118 | } 119 | 120 | /** 121 | * Tries to calculate the size by adding the size of each stream. 122 | * 123 | * If any of the streams do not return a valid number, then the size of the 124 | * append stream cannot be determined and null is returned. 125 | */ 126 | public function getSize(): ?int 127 | { 128 | $size = 0; 129 | 130 | foreach ($this->streams as $stream) { 131 | $s = $stream->getSize(); 132 | if ($s === null) { 133 | return null; 134 | } 135 | $size += $s; 136 | } 137 | 138 | return $size; 139 | } 140 | 141 | public function eof(): bool 142 | { 143 | return !$this->streams 144 | || ($this->current >= count($this->streams) - 1 145 | && $this->streams[$this->current]->eof()); 146 | } 147 | 148 | public function rewind(): void 149 | { 150 | $this->seek(0); 151 | } 152 | 153 | /** 154 | * Attempts to seek to the given position. Only supports SEEK_SET. 155 | */ 156 | public function seek($offset, $whence = SEEK_SET): void 157 | { 158 | if (!$this->seekable) { 159 | throw new \RuntimeException('This AppendStream is not seekable'); 160 | } elseif ($whence !== SEEK_SET) { 161 | throw new \RuntimeException('The AppendStream can only seek with SEEK_SET'); 162 | } 163 | 164 | $this->pos = $this->current = 0; 165 | 166 | // Rewind each stream 167 | foreach ($this->streams as $i => $stream) { 168 | try { 169 | $stream->rewind(); 170 | } catch (\Exception $e) { 171 | throw new \RuntimeException('Unable to seek stream ' 172 | .$i.' of the AppendStream', 0, $e); 173 | } 174 | } 175 | 176 | // Seek to the actual position by reading from each stream 177 | while ($this->pos < $offset && !$this->eof()) { 178 | $result = $this->read(min(8096, $offset - $this->pos)); 179 | if ($result === '') { 180 | break; 181 | } 182 | } 183 | } 184 | 185 | /** 186 | * Reads from all of the appended streams until the length is met or EOF. 187 | */ 188 | public function read($length): string 189 | { 190 | $buffer = ''; 191 | $total = count($this->streams) - 1; 192 | $remaining = $length; 193 | $progressToNext = false; 194 | 195 | while ($remaining > 0) { 196 | // Progress to the next stream if needed. 197 | if ($progressToNext || $this->streams[$this->current]->eof()) { 198 | $progressToNext = false; 199 | if ($this->current === $total) { 200 | break; 201 | } 202 | ++$this->current; 203 | } 204 | 205 | $result = $this->streams[$this->current]->read($remaining); 206 | 207 | if ($result === '') { 208 | $progressToNext = true; 209 | continue; 210 | } 211 | 212 | $buffer .= $result; 213 | $remaining = $length - strlen($buffer); 214 | } 215 | 216 | $this->pos += strlen($buffer); 217 | 218 | return $buffer; 219 | } 220 | 221 | public function isReadable(): bool 222 | { 223 | return true; 224 | } 225 | 226 | public function isWritable(): bool 227 | { 228 | return false; 229 | } 230 | 231 | public function isSeekable(): bool 232 | { 233 | return $this->seekable; 234 | } 235 | 236 | public function write($string): int 237 | { 238 | throw new \RuntimeException('Cannot write to an AppendStream'); 239 | } 240 | 241 | /** 242 | * @return mixed 243 | */ 244 | public function getMetadata($key = null) 245 | { 246 | return $key ? null : []; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/BufferStream.php: -------------------------------------------------------------------------------- 1 | hwm = $hwm; 35 | } 36 | 37 | public function __toString(): string 38 | { 39 | return $this->getContents(); 40 | } 41 | 42 | public function getContents(): string 43 | { 44 | $buffer = $this->buffer; 45 | $this->buffer = ''; 46 | 47 | return $buffer; 48 | } 49 | 50 | public function close(): void 51 | { 52 | $this->buffer = ''; 53 | } 54 | 55 | public function detach() 56 | { 57 | $this->close(); 58 | 59 | return null; 60 | } 61 | 62 | public function getSize(): ?int 63 | { 64 | return strlen($this->buffer); 65 | } 66 | 67 | public function isReadable(): bool 68 | { 69 | return true; 70 | } 71 | 72 | public function isWritable(): bool 73 | { 74 | return true; 75 | } 76 | 77 | public function isSeekable(): bool 78 | { 79 | return false; 80 | } 81 | 82 | public function rewind(): void 83 | { 84 | $this->seek(0); 85 | } 86 | 87 | public function seek($offset, $whence = SEEK_SET): void 88 | { 89 | throw new \RuntimeException('Cannot seek a BufferStream'); 90 | } 91 | 92 | public function eof(): bool 93 | { 94 | return strlen($this->buffer) === 0; 95 | } 96 | 97 | public function tell(): int 98 | { 99 | throw new \RuntimeException('Cannot determine the position of a BufferStream'); 100 | } 101 | 102 | /** 103 | * Reads data from the buffer. 104 | */ 105 | public function read($length): string 106 | { 107 | $currentLength = strlen($this->buffer); 108 | 109 | if ($length >= $currentLength) { 110 | // No need to slice the buffer because we don't have enough data. 111 | $result = $this->buffer; 112 | $this->buffer = ''; 113 | } else { 114 | // Slice up the result to provide a subset of the buffer. 115 | $result = substr($this->buffer, 0, $length); 116 | $this->buffer = substr($this->buffer, $length); 117 | } 118 | 119 | return $result; 120 | } 121 | 122 | /** 123 | * Writes data to the buffer. 124 | */ 125 | public function write($string): int 126 | { 127 | $this->buffer .= $string; 128 | 129 | if (strlen($this->buffer) >= $this->hwm) { 130 | return 0; 131 | } 132 | 133 | return strlen($string); 134 | } 135 | 136 | /** 137 | * @return mixed 138 | */ 139 | public function getMetadata($key = null) 140 | { 141 | if ($key === 'hwm') { 142 | return $this->hwm; 143 | } 144 | 145 | return $key ? null : []; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/CachingStream.php: -------------------------------------------------------------------------------- 1 | remoteStream = $stream; 39 | $this->stream = $target ?: new Stream(Utils::tryFopen('php://temp', 'r+')); 40 | } 41 | 42 | public function getSize(): ?int 43 | { 44 | $remoteSize = $this->remoteStream->getSize(); 45 | 46 | if (null === $remoteSize) { 47 | return null; 48 | } 49 | 50 | return max($this->stream->getSize(), $remoteSize); 51 | } 52 | 53 | public function rewind(): void 54 | { 55 | $this->seek(0); 56 | } 57 | 58 | public function seek($offset, $whence = SEEK_SET): void 59 | { 60 | if ($whence === SEEK_SET) { 61 | $byte = $offset; 62 | } elseif ($whence === SEEK_CUR) { 63 | $byte = $offset + $this->tell(); 64 | } elseif ($whence === SEEK_END) { 65 | $size = $this->remoteStream->getSize(); 66 | if ($size === null) { 67 | $size = $this->cacheEntireStream(); 68 | } 69 | $byte = $size + $offset; 70 | } else { 71 | throw new \InvalidArgumentException('Invalid whence'); 72 | } 73 | 74 | $diff = $byte - $this->stream->getSize(); 75 | 76 | if ($diff > 0) { 77 | // Read the remoteStream until we have read in at least the amount 78 | // of bytes requested, or we reach the end of the file. 79 | while ($diff > 0 && !$this->remoteStream->eof()) { 80 | $this->read($diff); 81 | $diff = $byte - $this->stream->getSize(); 82 | } 83 | } else { 84 | // We can just do a normal seek since we've already seen this byte. 85 | $this->stream->seek($byte); 86 | } 87 | } 88 | 89 | public function read($length): string 90 | { 91 | // Perform a regular read on any previously read data from the buffer 92 | $data = $this->stream->read($length); 93 | $remaining = $length - strlen($data); 94 | 95 | // More data was requested so read from the remote stream 96 | if ($remaining) { 97 | // If data was written to the buffer in a position that would have 98 | // been filled from the remote stream, then we must skip bytes on 99 | // the remote stream to emulate overwriting bytes from that 100 | // position. This mimics the behavior of other PHP stream wrappers. 101 | $remoteData = $this->remoteStream->read( 102 | $remaining + $this->skipReadBytes 103 | ); 104 | 105 | if ($this->skipReadBytes) { 106 | $len = strlen($remoteData); 107 | $remoteData = substr($remoteData, $this->skipReadBytes); 108 | $this->skipReadBytes = max(0, $this->skipReadBytes - $len); 109 | } 110 | 111 | $data .= $remoteData; 112 | $this->stream->write($remoteData); 113 | } 114 | 115 | return $data; 116 | } 117 | 118 | public function write($string): int 119 | { 120 | // When appending to the end of the currently read stream, you'll want 121 | // to skip bytes from being read from the remote stream to emulate 122 | // other stream wrappers. Basically replacing bytes of data of a fixed 123 | // length. 124 | $overflow = (strlen($string) + $this->tell()) - $this->remoteStream->tell(); 125 | if ($overflow > 0) { 126 | $this->skipReadBytes += $overflow; 127 | } 128 | 129 | return $this->stream->write($string); 130 | } 131 | 132 | public function eof(): bool 133 | { 134 | return $this->stream->eof() && $this->remoteStream->eof(); 135 | } 136 | 137 | /** 138 | * Close both the remote stream and buffer stream 139 | */ 140 | public function close(): void 141 | { 142 | $this->remoteStream->close(); 143 | $this->stream->close(); 144 | } 145 | 146 | private function cacheEntireStream(): int 147 | { 148 | $target = new FnStream(['write' => 'strlen']); 149 | Utils::copyToStream($this, $target); 150 | 151 | return $this->tell(); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/DroppingStream.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 30 | $this->maxLength = $maxLength; 31 | } 32 | 33 | public function write($string): int 34 | { 35 | $diff = $this->maxLength - $this->stream->getSize(); 36 | 37 | // Begin returning 0 when the underlying stream is too large. 38 | if ($diff <= 0) { 39 | return 0; 40 | } 41 | 42 | // Write the stream or a subset of the stream if needed. 43 | if (strlen($string) < $diff) { 44 | return $this->stream->write($string); 45 | } 46 | 47 | return $this->stream->write(substr($string, 0, $diff)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Exception/MalformedUriException.php: -------------------------------------------------------------------------------- 1 | */ 25 | private $methods; 26 | 27 | /** 28 | * @param array $methods Hash of method name to a callable. 29 | */ 30 | public function __construct(array $methods) 31 | { 32 | $this->methods = $methods; 33 | 34 | // Create the functions on the class 35 | foreach ($methods as $name => $fn) { 36 | $this->{'_fn_'.$name} = $fn; 37 | } 38 | } 39 | 40 | /** 41 | * Lazily determine which methods are not implemented. 42 | * 43 | * @throws \BadMethodCallException 44 | */ 45 | public function __get(string $name): void 46 | { 47 | throw new \BadMethodCallException(str_replace('_fn_', '', $name) 48 | .'() is not implemented in the FnStream'); 49 | } 50 | 51 | /** 52 | * The close method is called on the underlying stream only if possible. 53 | */ 54 | public function __destruct() 55 | { 56 | if (isset($this->_fn_close)) { 57 | ($this->_fn_close)(); 58 | } 59 | } 60 | 61 | /** 62 | * An unserialize would allow the __destruct to run when the unserialized value goes out of scope. 63 | * 64 | * @throws \LogicException 65 | */ 66 | public function __wakeup(): void 67 | { 68 | throw new \LogicException('FnStream should never be unserialized'); 69 | } 70 | 71 | /** 72 | * Adds custom functionality to an underlying stream by intercepting 73 | * specific method calls. 74 | * 75 | * @param StreamInterface $stream Stream to decorate 76 | * @param array $methods Hash of method name to a closure 77 | * 78 | * @return FnStream 79 | */ 80 | public static function decorate(StreamInterface $stream, array $methods) 81 | { 82 | // If any of the required methods were not provided, then simply 83 | // proxy to the decorated stream. 84 | foreach (array_diff(self::SLOTS, array_keys($methods)) as $diff) { 85 | /** @var callable $callable */ 86 | $callable = [$stream, $diff]; 87 | $methods[$diff] = $callable; 88 | } 89 | 90 | return new self($methods); 91 | } 92 | 93 | public function __toString(): string 94 | { 95 | try { 96 | /** @var string */ 97 | return ($this->_fn___toString)(); 98 | } catch (\Throwable $e) { 99 | if (\PHP_VERSION_ID >= 70400) { 100 | throw $e; 101 | } 102 | trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); 103 | 104 | return ''; 105 | } 106 | } 107 | 108 | public function close(): void 109 | { 110 | ($this->_fn_close)(); 111 | } 112 | 113 | public function detach() 114 | { 115 | return ($this->_fn_detach)(); 116 | } 117 | 118 | public function getSize(): ?int 119 | { 120 | return ($this->_fn_getSize)(); 121 | } 122 | 123 | public function tell(): int 124 | { 125 | return ($this->_fn_tell)(); 126 | } 127 | 128 | public function eof(): bool 129 | { 130 | return ($this->_fn_eof)(); 131 | } 132 | 133 | public function isSeekable(): bool 134 | { 135 | return ($this->_fn_isSeekable)(); 136 | } 137 | 138 | public function rewind(): void 139 | { 140 | ($this->_fn_rewind)(); 141 | } 142 | 143 | public function seek($offset, $whence = SEEK_SET): void 144 | { 145 | ($this->_fn_seek)($offset, $whence); 146 | } 147 | 148 | public function isWritable(): bool 149 | { 150 | return ($this->_fn_isWritable)(); 151 | } 152 | 153 | public function write($string): int 154 | { 155 | return ($this->_fn_write)($string); 156 | } 157 | 158 | public function isReadable(): bool 159 | { 160 | return ($this->_fn_isReadable)(); 161 | } 162 | 163 | public function read($length): string 164 | { 165 | return ($this->_fn_read)($length); 166 | } 167 | 168 | public function getContents(): string 169 | { 170 | return ($this->_fn_getContents)(); 171 | } 172 | 173 | /** 174 | * @return mixed 175 | */ 176 | public function getMetadata($key = null) 177 | { 178 | return ($this->_fn_getMetadata)($key); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Header.php: -------------------------------------------------------------------------------- 1 | ]+>|[^=]+/', $kvp, $matches)) { 27 | $m = $matches[0]; 28 | if (isset($m[1])) { 29 | $part[trim($m[0], $trimmed)] = trim($m[1], $trimmed); 30 | } else { 31 | $part[] = trim($m[0], $trimmed); 32 | } 33 | } 34 | } 35 | if ($part) { 36 | $params[] = $part; 37 | } 38 | } 39 | } 40 | 41 | return $params; 42 | } 43 | 44 | /** 45 | * Converts an array of header values that may contain comma separated 46 | * headers into an array of headers with no comma separated values. 47 | * 48 | * @param string|array $header Header to normalize. 49 | * 50 | * @deprecated Use self::splitList() instead. 51 | */ 52 | public static function normalize($header): array 53 | { 54 | $result = []; 55 | foreach ((array) $header as $value) { 56 | foreach (self::splitList($value) as $parsed) { 57 | $result[] = $parsed; 58 | } 59 | } 60 | 61 | return $result; 62 | } 63 | 64 | /** 65 | * Splits a HTTP header defined to contain a comma-separated list into 66 | * each individual value. Empty values will be removed. 67 | * 68 | * Example headers include 'accept', 'cache-control' and 'if-none-match'. 69 | * 70 | * This method must not be used to parse headers that are not defined as 71 | * a list, such as 'user-agent' or 'set-cookie'. 72 | * 73 | * @param string|string[] $values Header value as returned by MessageInterface::getHeader() 74 | * 75 | * @return string[] 76 | */ 77 | public static function splitList($values): array 78 | { 79 | if (!\is_array($values)) { 80 | $values = [$values]; 81 | } 82 | 83 | $result = []; 84 | foreach ($values as $value) { 85 | if (!\is_string($value)) { 86 | throw new \TypeError('$header must either be a string or an array containing strings.'); 87 | } 88 | 89 | $v = ''; 90 | $isQuoted = false; 91 | $isEscaped = false; 92 | for ($i = 0, $max = \strlen($value); $i < $max; ++$i) { 93 | if ($isEscaped) { 94 | $v .= $value[$i]; 95 | $isEscaped = false; 96 | 97 | continue; 98 | } 99 | 100 | if (!$isQuoted && $value[$i] === ',') { 101 | $v = \trim($v); 102 | if ($v !== '') { 103 | $result[] = $v; 104 | } 105 | 106 | $v = ''; 107 | continue; 108 | } 109 | 110 | if ($isQuoted && $value[$i] === '\\') { 111 | $isEscaped = true; 112 | $v .= $value[$i]; 113 | 114 | continue; 115 | } 116 | if ($value[$i] === '"') { 117 | $isQuoted = !$isQuoted; 118 | $v .= $value[$i]; 119 | 120 | continue; 121 | } 122 | 123 | $v .= $value[$i]; 124 | } 125 | 126 | $v = \trim($v); 127 | if ($v !== '') { 128 | $result[] = $v; 129 | } 130 | } 131 | 132 | return $result; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/HttpFactory.php: -------------------------------------------------------------------------------- 1 | getSize(); 37 | } 38 | 39 | return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType); 40 | } 41 | 42 | public function createStream(string $content = ''): StreamInterface 43 | { 44 | return Utils::streamFor($content); 45 | } 46 | 47 | public function createStreamFromFile(string $file, string $mode = 'r'): StreamInterface 48 | { 49 | try { 50 | $resource = Utils::tryFopen($file, $mode); 51 | } catch (\RuntimeException $e) { 52 | if ('' === $mode || false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true)) { 53 | throw new \InvalidArgumentException(sprintf('Invalid file opening mode "%s"', $mode), 0, $e); 54 | } 55 | 56 | throw $e; 57 | } 58 | 59 | return Utils::streamFor($resource); 60 | } 61 | 62 | public function createStreamFromResource($resource): StreamInterface 63 | { 64 | return Utils::streamFor($resource); 65 | } 66 | 67 | public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface 68 | { 69 | if (empty($method)) { 70 | if (!empty($serverParams['REQUEST_METHOD'])) { 71 | $method = $serverParams['REQUEST_METHOD']; 72 | } else { 73 | throw new \InvalidArgumentException('Cannot determine HTTP method'); 74 | } 75 | } 76 | 77 | return new ServerRequest($method, $uri, [], null, '1.1', $serverParams); 78 | } 79 | 80 | public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface 81 | { 82 | return new Response($code, [], null, '1.1', $reasonPhrase); 83 | } 84 | 85 | public function createRequest(string $method, $uri): RequestInterface 86 | { 87 | return new Request($method, $uri); 88 | } 89 | 90 | public function createUri(string $uri = ''): UriInterface 91 | { 92 | return new Uri($uri); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/InflateStream.php: -------------------------------------------------------------------------------- 1 | 15 + 32]); 35 | $this->stream = $stream->isSeekable() ? new Stream($resource) : new NoSeekStream(new Stream($resource)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/LazyOpenStream.php: -------------------------------------------------------------------------------- 1 | filename = $filename; 35 | $this->mode = $mode; 36 | 37 | // unsetting the property forces the first access to go through 38 | // __get(). 39 | unset($this->stream); 40 | } 41 | 42 | /** 43 | * Creates the underlying stream lazily when required. 44 | */ 45 | protected function createStream(): StreamInterface 46 | { 47 | return Utils::streamFor(Utils::tryFopen($this->filename, $this->mode)); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/LimitStream.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 38 | $this->setLimit($limit); 39 | $this->setOffset($offset); 40 | } 41 | 42 | public function eof(): bool 43 | { 44 | // Always return true if the underlying stream is EOF 45 | if ($this->stream->eof()) { 46 | return true; 47 | } 48 | 49 | // No limit and the underlying stream is not at EOF 50 | if ($this->limit === -1) { 51 | return false; 52 | } 53 | 54 | return $this->stream->tell() >= $this->offset + $this->limit; 55 | } 56 | 57 | /** 58 | * Returns the size of the limited subset of data 59 | */ 60 | public function getSize(): ?int 61 | { 62 | if (null === ($length = $this->stream->getSize())) { 63 | return null; 64 | } elseif ($this->limit === -1) { 65 | return $length - $this->offset; 66 | } else { 67 | return min($this->limit, $length - $this->offset); 68 | } 69 | } 70 | 71 | /** 72 | * Allow for a bounded seek on the read limited stream 73 | */ 74 | public function seek($offset, $whence = SEEK_SET): void 75 | { 76 | if ($whence !== SEEK_SET || $offset < 0) { 77 | throw new \RuntimeException(sprintf( 78 | 'Cannot seek to offset %s with whence %s', 79 | $offset, 80 | $whence 81 | )); 82 | } 83 | 84 | $offset += $this->offset; 85 | 86 | if ($this->limit !== -1) { 87 | if ($offset > $this->offset + $this->limit) { 88 | $offset = $this->offset + $this->limit; 89 | } 90 | } 91 | 92 | $this->stream->seek($offset); 93 | } 94 | 95 | /** 96 | * Give a relative tell() 97 | */ 98 | public function tell(): int 99 | { 100 | return $this->stream->tell() - $this->offset; 101 | } 102 | 103 | /** 104 | * Set the offset to start limiting from 105 | * 106 | * @param int $offset Offset to seek to and begin byte limiting from 107 | * 108 | * @throws \RuntimeException if the stream cannot be seeked. 109 | */ 110 | public function setOffset(int $offset): void 111 | { 112 | $current = $this->stream->tell(); 113 | 114 | if ($current !== $offset) { 115 | // If the stream cannot seek to the offset position, then read to it 116 | if ($this->stream->isSeekable()) { 117 | $this->stream->seek($offset); 118 | } elseif ($current > $offset) { 119 | throw new \RuntimeException("Could not seek to stream offset $offset"); 120 | } else { 121 | $this->stream->read($offset - $current); 122 | } 123 | } 124 | 125 | $this->offset = $offset; 126 | } 127 | 128 | /** 129 | * Set the limit of bytes that the decorator allows to be read from the 130 | * stream. 131 | * 132 | * @param int $limit Number of bytes to allow to be read from the stream. 133 | * Use -1 for no limit. 134 | */ 135 | public function setLimit(int $limit): void 136 | { 137 | $this->limit = $limit; 138 | } 139 | 140 | public function read($length): string 141 | { 142 | if ($this->limit === -1) { 143 | return $this->stream->read($length); 144 | } 145 | 146 | // Check if the current position is less than the total allowed 147 | // bytes + original offset 148 | $remaining = ($this->offset + $this->limit) - $this->stream->tell(); 149 | if ($remaining > 0) { 150 | // Only return the amount of requested data, ensuring that the byte 151 | // limit is not exceeded 152 | return $this->stream->read(min($remaining, $length)); 153 | } 154 | 155 | return ''; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | getMethod().' ' 22 | .$message->getRequestTarget()) 23 | .' HTTP/'.$message->getProtocolVersion(); 24 | if (!$message->hasHeader('host')) { 25 | $msg .= "\r\nHost: ".$message->getUri()->getHost(); 26 | } 27 | } elseif ($message instanceof ResponseInterface) { 28 | $msg = 'HTTP/'.$message->getProtocolVersion().' ' 29 | .$message->getStatusCode().' ' 30 | .$message->getReasonPhrase(); 31 | } else { 32 | throw new \InvalidArgumentException('Unknown message type'); 33 | } 34 | 35 | foreach ($message->getHeaders() as $name => $values) { 36 | if (is_string($name) && strtolower($name) === 'set-cookie') { 37 | foreach ($values as $value) { 38 | $msg .= "\r\n{$name}: ".$value; 39 | } 40 | } else { 41 | $msg .= "\r\n{$name}: ".implode(', ', $values); 42 | } 43 | } 44 | 45 | return "{$msg}\r\n\r\n".$message->getBody(); 46 | } 47 | 48 | /** 49 | * Get a short summary of the message body. 50 | * 51 | * Will return `null` if the response is not printable. 52 | * 53 | * @param MessageInterface $message The message to get the body summary 54 | * @param int $truncateAt The maximum allowed size of the summary 55 | */ 56 | public static function bodySummary(MessageInterface $message, int $truncateAt = 120): ?string 57 | { 58 | $body = $message->getBody(); 59 | 60 | if (!$body->isSeekable() || !$body->isReadable()) { 61 | return null; 62 | } 63 | 64 | $size = $body->getSize(); 65 | 66 | if ($size === 0) { 67 | return null; 68 | } 69 | 70 | $body->rewind(); 71 | $summary = $body->read($truncateAt); 72 | $body->rewind(); 73 | 74 | if ($size > $truncateAt) { 75 | $summary .= ' (truncated...)'; 76 | } 77 | 78 | // Matches any printable character, including unicode characters: 79 | // letters, marks, numbers, punctuation, spacing, and separators. 80 | if (preg_match('/[^\pL\pM\pN\pP\pS\pZ\n\r\t]/u', $summary) !== 0) { 81 | return null; 82 | } 83 | 84 | return $summary; 85 | } 86 | 87 | /** 88 | * Attempts to rewind a message body and throws an exception on failure. 89 | * 90 | * The body of the message will only be rewound if a call to `tell()` 91 | * returns a value other than `0`. 92 | * 93 | * @param MessageInterface $message Message to rewind 94 | * 95 | * @throws \RuntimeException 96 | */ 97 | public static function rewindBody(MessageInterface $message): void 98 | { 99 | $body = $message->getBody(); 100 | 101 | if ($body->tell()) { 102 | $body->rewind(); 103 | } 104 | } 105 | 106 | /** 107 | * Parses an HTTP message into an associative array. 108 | * 109 | * The array contains the "start-line" key containing the start line of 110 | * the message, "headers" key containing an associative array of header 111 | * array values, and a "body" key containing the body of the message. 112 | * 113 | * @param string $message HTTP request or response to parse. 114 | */ 115 | public static function parseMessage(string $message): array 116 | { 117 | if (!$message) { 118 | throw new \InvalidArgumentException('Invalid message'); 119 | } 120 | 121 | $message = ltrim($message, "\r\n"); 122 | 123 | $messageParts = preg_split("/\r?\n\r?\n/", $message, 2); 124 | 125 | if ($messageParts === false || count($messageParts) !== 2) { 126 | throw new \InvalidArgumentException('Invalid message: Missing header delimiter'); 127 | } 128 | 129 | [$rawHeaders, $body] = $messageParts; 130 | $rawHeaders .= "\r\n"; // Put back the delimiter we split previously 131 | $headerParts = preg_split("/\r?\n/", $rawHeaders, 2); 132 | 133 | if ($headerParts === false || count($headerParts) !== 2) { 134 | throw new \InvalidArgumentException('Invalid message: Missing status line'); 135 | } 136 | 137 | [$startLine, $rawHeaders] = $headerParts; 138 | 139 | if (preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches) && $matches[1] === '1.0') { 140 | // Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0 141 | $rawHeaders = preg_replace(Rfc7230::HEADER_FOLD_REGEX, ' ', $rawHeaders); 142 | } 143 | 144 | /** @var array[] $headerLines */ 145 | $count = preg_match_all(Rfc7230::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER); 146 | 147 | // If these aren't the same, then one line didn't match and there's an invalid header. 148 | if ($count !== substr_count($rawHeaders, "\n")) { 149 | // Folding is deprecated, see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4 150 | if (preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders)) { 151 | throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding'); 152 | } 153 | 154 | throw new \InvalidArgumentException('Invalid header syntax'); 155 | } 156 | 157 | $headers = []; 158 | 159 | foreach ($headerLines as $headerLine) { 160 | $headers[$headerLine[1]][] = $headerLine[2]; 161 | } 162 | 163 | return [ 164 | 'start-line' => $startLine, 165 | 'headers' => $headers, 166 | 'body' => $body, 167 | ]; 168 | } 169 | 170 | /** 171 | * Constructs a URI for an HTTP request message. 172 | * 173 | * @param string $path Path from the start-line 174 | * @param array $headers Array of headers (each value an array). 175 | */ 176 | public static function parseRequestUri(string $path, array $headers): string 177 | { 178 | $hostKey = array_filter(array_keys($headers), function ($k) { 179 | // Numeric array keys are converted to int by PHP. 180 | $k = (string) $k; 181 | 182 | return strtolower($k) === 'host'; 183 | }); 184 | 185 | // If no host is found, then a full URI cannot be constructed. 186 | if (!$hostKey) { 187 | return $path; 188 | } 189 | 190 | $host = $headers[reset($hostKey)][0]; 191 | $scheme = substr($host, -4) === ':443' ? 'https' : 'http'; 192 | 193 | return $scheme.'://'.$host.'/'.ltrim($path, '/'); 194 | } 195 | 196 | /** 197 | * Parses a request message string into a request object. 198 | * 199 | * @param string $message Request message string. 200 | */ 201 | public static function parseRequest(string $message): RequestInterface 202 | { 203 | $data = self::parseMessage($message); 204 | $matches = []; 205 | if (!preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches)) { 206 | throw new \InvalidArgumentException('Invalid request string'); 207 | } 208 | $parts = explode(' ', $data['start-line'], 3); 209 | $version = isset($parts[2]) ? explode('/', $parts[2])[1] : '1.1'; 210 | 211 | $request = new Request( 212 | $parts[0], 213 | $matches[1] === '/' ? self::parseRequestUri($parts[1], $data['headers']) : $parts[1], 214 | $data['headers'], 215 | $data['body'], 216 | $version 217 | ); 218 | 219 | return $matches[1] === '/' ? $request : $request->withRequestTarget($parts[1]); 220 | } 221 | 222 | /** 223 | * Parses a response message string into a response object. 224 | * 225 | * @param string $message Response message string. 226 | */ 227 | public static function parseResponse(string $message): ResponseInterface 228 | { 229 | $data = self::parseMessage($message); 230 | // According to https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2 231 | // the space between status-code and reason-phrase is required. But 232 | // browsers accept responses without space and reason as well. 233 | if (!preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/', $data['start-line'])) { 234 | throw new \InvalidArgumentException('Invalid response string: '.$data['start-line']); 235 | } 236 | $parts = explode(' ', $data['start-line'], 3); 237 | 238 | return new Response( 239 | (int) $parts[1], 240 | $data['headers'], 241 | $data['body'], 242 | explode('/', $parts[0])[1], 243 | $parts[2] ?? null 244 | ); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/MessageTrait.php: -------------------------------------------------------------------------------- 1 | array of values */ 16 | private $headers = []; 17 | 18 | /** @var string[] Map of lowercase header name => original name at registration */ 19 | private $headerNames = []; 20 | 21 | /** @var string */ 22 | private $protocol = '1.1'; 23 | 24 | /** @var StreamInterface|null */ 25 | private $stream; 26 | 27 | public function getProtocolVersion(): string 28 | { 29 | return $this->protocol; 30 | } 31 | 32 | public function withProtocolVersion($version): MessageInterface 33 | { 34 | if ($this->protocol === $version) { 35 | return $this; 36 | } 37 | 38 | $new = clone $this; 39 | $new->protocol = $version; 40 | 41 | return $new; 42 | } 43 | 44 | public function getHeaders(): array 45 | { 46 | return $this->headers; 47 | } 48 | 49 | public function hasHeader($header): bool 50 | { 51 | return isset($this->headerNames[strtolower($header)]); 52 | } 53 | 54 | public function getHeader($header): array 55 | { 56 | $header = strtolower($header); 57 | 58 | if (!isset($this->headerNames[$header])) { 59 | return []; 60 | } 61 | 62 | $header = $this->headerNames[$header]; 63 | 64 | return $this->headers[$header]; 65 | } 66 | 67 | public function getHeaderLine($header): string 68 | { 69 | return implode(', ', $this->getHeader($header)); 70 | } 71 | 72 | public function withHeader($header, $value): MessageInterface 73 | { 74 | $this->assertHeader($header); 75 | $value = $this->normalizeHeaderValue($value); 76 | $normalized = strtolower($header); 77 | 78 | $new = clone $this; 79 | if (isset($new->headerNames[$normalized])) { 80 | unset($new->headers[$new->headerNames[$normalized]]); 81 | } 82 | $new->headerNames[$normalized] = $header; 83 | $new->headers[$header] = $value; 84 | 85 | return $new; 86 | } 87 | 88 | public function withAddedHeader($header, $value): MessageInterface 89 | { 90 | $this->assertHeader($header); 91 | $value = $this->normalizeHeaderValue($value); 92 | $normalized = strtolower($header); 93 | 94 | $new = clone $this; 95 | if (isset($new->headerNames[$normalized])) { 96 | $header = $this->headerNames[$normalized]; 97 | $new->headers[$header] = array_merge($this->headers[$header], $value); 98 | } else { 99 | $new->headerNames[$normalized] = $header; 100 | $new->headers[$header] = $value; 101 | } 102 | 103 | return $new; 104 | } 105 | 106 | public function withoutHeader($header): MessageInterface 107 | { 108 | $normalized = strtolower($header); 109 | 110 | if (!isset($this->headerNames[$normalized])) { 111 | return $this; 112 | } 113 | 114 | $header = $this->headerNames[$normalized]; 115 | 116 | $new = clone $this; 117 | unset($new->headers[$header], $new->headerNames[$normalized]); 118 | 119 | return $new; 120 | } 121 | 122 | public function getBody(): StreamInterface 123 | { 124 | if (!$this->stream) { 125 | $this->stream = Utils::streamFor(''); 126 | } 127 | 128 | return $this->stream; 129 | } 130 | 131 | public function withBody(StreamInterface $body): MessageInterface 132 | { 133 | if ($body === $this->stream) { 134 | return $this; 135 | } 136 | 137 | $new = clone $this; 138 | $new->stream = $body; 139 | 140 | return $new; 141 | } 142 | 143 | /** 144 | * @param (string|string[])[] $headers 145 | */ 146 | private function setHeaders(array $headers): void 147 | { 148 | $this->headerNames = $this->headers = []; 149 | foreach ($headers as $header => $value) { 150 | // Numeric array keys are converted to int by PHP. 151 | $header = (string) $header; 152 | 153 | $this->assertHeader($header); 154 | $value = $this->normalizeHeaderValue($value); 155 | $normalized = strtolower($header); 156 | if (isset($this->headerNames[$normalized])) { 157 | $header = $this->headerNames[$normalized]; 158 | $this->headers[$header] = array_merge($this->headers[$header], $value); 159 | } else { 160 | $this->headerNames[$normalized] = $header; 161 | $this->headers[$header] = $value; 162 | } 163 | } 164 | } 165 | 166 | /** 167 | * @param mixed $value 168 | * 169 | * @return string[] 170 | */ 171 | private function normalizeHeaderValue($value): array 172 | { 173 | if (!is_array($value)) { 174 | return $this->trimAndValidateHeaderValues([$value]); 175 | } 176 | 177 | if (count($value) === 0) { 178 | throw new \InvalidArgumentException('Header value can not be an empty array.'); 179 | } 180 | 181 | return $this->trimAndValidateHeaderValues($value); 182 | } 183 | 184 | /** 185 | * Trims whitespace from the header values. 186 | * 187 | * Spaces and tabs ought to be excluded by parsers when extracting the field value from a header field. 188 | * 189 | * header-field = field-name ":" OWS field-value OWS 190 | * OWS = *( SP / HTAB ) 191 | * 192 | * @param mixed[] $values Header values 193 | * 194 | * @return string[] Trimmed header values 195 | * 196 | * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4 197 | */ 198 | private function trimAndValidateHeaderValues(array $values): array 199 | { 200 | return array_map(function ($value) { 201 | if (!is_scalar($value) && null !== $value) { 202 | throw new \InvalidArgumentException(sprintf( 203 | 'Header value must be scalar or null but %s provided.', 204 | is_object($value) ? get_class($value) : gettype($value) 205 | )); 206 | } 207 | 208 | $trimmed = trim((string) $value, " \t"); 209 | $this->assertValue($trimmed); 210 | 211 | return $trimmed; 212 | }, array_values($values)); 213 | } 214 | 215 | /** 216 | * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 217 | * 218 | * @param mixed $header 219 | */ 220 | private function assertHeader($header): void 221 | { 222 | if (!is_string($header)) { 223 | throw new \InvalidArgumentException(sprintf( 224 | 'Header name must be a string but %s provided.', 225 | is_object($header) ? get_class($header) : gettype($header) 226 | )); 227 | } 228 | 229 | if (!preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $header)) { 230 | throw new \InvalidArgumentException( 231 | sprintf('"%s" is not valid header name.', $header) 232 | ); 233 | } 234 | } 235 | 236 | /** 237 | * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2 238 | * 239 | * field-value = *( field-content / obs-fold ) 240 | * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] 241 | * field-vchar = VCHAR / obs-text 242 | * VCHAR = %x21-7E 243 | * obs-text = %x80-FF 244 | * obs-fold = CRLF 1*( SP / HTAB ) 245 | */ 246 | private function assertValue(string $value): void 247 | { 248 | // The regular expression intentionally does not support the obs-fold production, because as 249 | // per RFC 7230#3.2.4: 250 | // 251 | // A sender MUST NOT generate a message that includes 252 | // line folding (i.e., that has any field-value that contains a match to 253 | // the obs-fold rule) unless the message is intended for packaging 254 | // within the message/http media type. 255 | // 256 | // Clients must not send a request with line folding and a server sending folded headers is 257 | // likely very rare. Line folding is a fairly obscure feature of HTTP/1.1 and thus not accepting 258 | // folding is not likely to break any legitimate use case. 259 | if (!preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value)) { 260 | throw new \InvalidArgumentException( 261 | sprintf('"%s" is not valid header value.', $value) 262 | ); 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/MultipartStream.php: -------------------------------------------------------------------------------- 1 | boundary = $boundary ?: bin2hex(random_bytes(20)); 38 | $this->stream = $this->createStream($elements); 39 | } 40 | 41 | public function getBoundary(): string 42 | { 43 | return $this->boundary; 44 | } 45 | 46 | public function isWritable(): bool 47 | { 48 | return false; 49 | } 50 | 51 | /** 52 | * Get the headers needed before transferring the content of a POST file 53 | * 54 | * @param string[] $headers 55 | */ 56 | private function getHeaders(array $headers): string 57 | { 58 | $str = ''; 59 | foreach ($headers as $key => $value) { 60 | $str .= "{$key}: {$value}\r\n"; 61 | } 62 | 63 | return "--{$this->boundary}\r\n".trim($str)."\r\n\r\n"; 64 | } 65 | 66 | /** 67 | * Create the aggregate stream that will be used to upload the POST data 68 | */ 69 | protected function createStream(array $elements = []): StreamInterface 70 | { 71 | $stream = new AppendStream(); 72 | 73 | foreach ($elements as $element) { 74 | if (!is_array($element)) { 75 | throw new \UnexpectedValueException('An array is expected'); 76 | } 77 | $this->addElement($stream, $element); 78 | } 79 | 80 | // Add the trailing boundary with CRLF 81 | $stream->addStream(Utils::streamFor("--{$this->boundary}--\r\n")); 82 | 83 | return $stream; 84 | } 85 | 86 | private function addElement(AppendStream $stream, array $element): void 87 | { 88 | foreach (['contents', 'name'] as $key) { 89 | if (!array_key_exists($key, $element)) { 90 | throw new \InvalidArgumentException("A '{$key}' key is required"); 91 | } 92 | } 93 | 94 | $element['contents'] = Utils::streamFor($element['contents']); 95 | 96 | if (empty($element['filename'])) { 97 | $uri = $element['contents']->getMetadata('uri'); 98 | if ($uri && \is_string($uri) && \substr($uri, 0, 6) !== 'php://' && \substr($uri, 0, 7) !== 'data://') { 99 | $element['filename'] = $uri; 100 | } 101 | } 102 | 103 | [$body, $headers] = $this->createElement( 104 | $element['name'], 105 | $element['contents'], 106 | $element['filename'] ?? null, 107 | $element['headers'] ?? [] 108 | ); 109 | 110 | $stream->addStream(Utils::streamFor($this->getHeaders($headers))); 111 | $stream->addStream($body); 112 | $stream->addStream(Utils::streamFor("\r\n")); 113 | } 114 | 115 | /** 116 | * @param string[] $headers 117 | * 118 | * @return array{0: StreamInterface, 1: string[]} 119 | */ 120 | private function createElement(string $name, StreamInterface $stream, ?string $filename, array $headers): array 121 | { 122 | // Set a default content-disposition header if one was no provided 123 | $disposition = self::getHeader($headers, 'content-disposition'); 124 | if (!$disposition) { 125 | $headers['Content-Disposition'] = ($filename === '0' || $filename) 126 | ? sprintf( 127 | 'form-data; name="%s"; filename="%s"', 128 | $name, 129 | basename($filename) 130 | ) 131 | : "form-data; name=\"{$name}\""; 132 | } 133 | 134 | // Set a default content-length header if one was no provided 135 | $length = self::getHeader($headers, 'content-length'); 136 | if (!$length) { 137 | if ($length = $stream->getSize()) { 138 | $headers['Content-Length'] = (string) $length; 139 | } 140 | } 141 | 142 | // Set a default Content-Type if one was not supplied 143 | $type = self::getHeader($headers, 'content-type'); 144 | if (!$type && ($filename === '0' || $filename)) { 145 | $headers['Content-Type'] = MimeType::fromFilename($filename) ?? 'application/octet-stream'; 146 | } 147 | 148 | return [$stream, $headers]; 149 | } 150 | 151 | /** 152 | * @param string[] $headers 153 | */ 154 | private static function getHeader(array $headers, string $key): ?string 155 | { 156 | $lowercaseHeader = strtolower($key); 157 | foreach ($headers as $k => $v) { 158 | if (strtolower((string) $k) === $lowercaseHeader) { 159 | return $v; 160 | } 161 | } 162 | 163 | return null; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/NoSeekStream.php: -------------------------------------------------------------------------------- 1 | source = $source; 49 | $this->size = $options['size'] ?? null; 50 | $this->metadata = $options['metadata'] ?? []; 51 | $this->buffer = new BufferStream(); 52 | } 53 | 54 | public function __toString(): string 55 | { 56 | try { 57 | return Utils::copyToString($this); 58 | } catch (\Throwable $e) { 59 | if (\PHP_VERSION_ID >= 70400) { 60 | throw $e; 61 | } 62 | trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); 63 | 64 | return ''; 65 | } 66 | } 67 | 68 | public function close(): void 69 | { 70 | $this->detach(); 71 | } 72 | 73 | public function detach() 74 | { 75 | $this->tellPos = 0; 76 | $this->source = null; 77 | 78 | return null; 79 | } 80 | 81 | public function getSize(): ?int 82 | { 83 | return $this->size; 84 | } 85 | 86 | public function tell(): int 87 | { 88 | return $this->tellPos; 89 | } 90 | 91 | public function eof(): bool 92 | { 93 | return $this->source === null; 94 | } 95 | 96 | public function isSeekable(): bool 97 | { 98 | return false; 99 | } 100 | 101 | public function rewind(): void 102 | { 103 | $this->seek(0); 104 | } 105 | 106 | public function seek($offset, $whence = SEEK_SET): void 107 | { 108 | throw new \RuntimeException('Cannot seek a PumpStream'); 109 | } 110 | 111 | public function isWritable(): bool 112 | { 113 | return false; 114 | } 115 | 116 | public function write($string): int 117 | { 118 | throw new \RuntimeException('Cannot write to a PumpStream'); 119 | } 120 | 121 | public function isReadable(): bool 122 | { 123 | return true; 124 | } 125 | 126 | public function read($length): string 127 | { 128 | $data = $this->buffer->read($length); 129 | $readLen = strlen($data); 130 | $this->tellPos += $readLen; 131 | $remaining = $length - $readLen; 132 | 133 | if ($remaining) { 134 | $this->pump($remaining); 135 | $data .= $this->buffer->read($remaining); 136 | $this->tellPos += strlen($data) - $readLen; 137 | } 138 | 139 | return $data; 140 | } 141 | 142 | public function getContents(): string 143 | { 144 | $result = ''; 145 | while (!$this->eof()) { 146 | $result .= $this->read(1000000); 147 | } 148 | 149 | return $result; 150 | } 151 | 152 | /** 153 | * @return mixed 154 | */ 155 | public function getMetadata($key = null) 156 | { 157 | if (!$key) { 158 | return $this->metadata; 159 | } 160 | 161 | return $this->metadata[$key] ?? null; 162 | } 163 | 164 | private function pump(int $length): void 165 | { 166 | if ($this->source !== null) { 167 | do { 168 | $data = ($this->source)($length); 169 | if ($data === false || $data === null) { 170 | $this->source = null; 171 | 172 | return; 173 | } 174 | $this->buffer->write($data); 175 | $length -= strlen($data); 176 | } while ($length > 0); 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Query.php: -------------------------------------------------------------------------------- 1 | '1', 'foo[b]' => '2'])`. 16 | * 17 | * @param string $str Query string to parse 18 | * @param int|bool $urlEncoding How the query string is encoded 19 | */ 20 | public static function parse(string $str, $urlEncoding = true): array 21 | { 22 | $result = []; 23 | 24 | if ($str === '') { 25 | return $result; 26 | } 27 | 28 | if ($urlEncoding === true) { 29 | $decoder = function ($value) { 30 | return rawurldecode(str_replace('+', ' ', (string) $value)); 31 | }; 32 | } elseif ($urlEncoding === PHP_QUERY_RFC3986) { 33 | $decoder = 'rawurldecode'; 34 | } elseif ($urlEncoding === PHP_QUERY_RFC1738) { 35 | $decoder = 'urldecode'; 36 | } else { 37 | $decoder = function ($str) { 38 | return $str; 39 | }; 40 | } 41 | 42 | foreach (explode('&', $str) as $kvp) { 43 | $parts = explode('=', $kvp, 2); 44 | $key = $decoder($parts[0]); 45 | $value = isset($parts[1]) ? $decoder($parts[1]) : null; 46 | if (!array_key_exists($key, $result)) { 47 | $result[$key] = $value; 48 | } else { 49 | if (!is_array($result[$key])) { 50 | $result[$key] = [$result[$key]]; 51 | } 52 | $result[$key][] = $value; 53 | } 54 | } 55 | 56 | return $result; 57 | } 58 | 59 | /** 60 | * Build a query string from an array of key value pairs. 61 | * 62 | * This function can use the return value of `parse()` to build a query 63 | * string. This function does not modify the provided keys when an array is 64 | * encountered (like `http_build_query()` would). 65 | * 66 | * @param array $params Query string parameters. 67 | * @param int|false $encoding Set to false to not encode, 68 | * PHP_QUERY_RFC3986 to encode using 69 | * RFC3986, or PHP_QUERY_RFC1738 to 70 | * encode using RFC1738. 71 | * @param bool $treatBoolsAsInts Set to true to encode as 0/1, and 72 | * false as false/true. 73 | */ 74 | public static function build(array $params, $encoding = PHP_QUERY_RFC3986, bool $treatBoolsAsInts = true): string 75 | { 76 | if (!$params) { 77 | return ''; 78 | } 79 | 80 | if ($encoding === false) { 81 | $encoder = function (string $str): string { 82 | return $str; 83 | }; 84 | } elseif ($encoding === PHP_QUERY_RFC3986) { 85 | $encoder = 'rawurlencode'; 86 | } elseif ($encoding === PHP_QUERY_RFC1738) { 87 | $encoder = 'urlencode'; 88 | } else { 89 | throw new \InvalidArgumentException('Invalid type'); 90 | } 91 | 92 | $castBool = $treatBoolsAsInts ? static function ($v) { return (int) $v; } : static function ($v) { return $v ? 'true' : 'false'; }; 93 | 94 | $qs = ''; 95 | foreach ($params as $k => $v) { 96 | $k = $encoder((string) $k); 97 | if (!is_array($v)) { 98 | $qs .= $k; 99 | $v = is_bool($v) ? $castBool($v) : $v; 100 | if ($v !== null) { 101 | $qs .= '='.$encoder((string) $v); 102 | } 103 | $qs .= '&'; 104 | } else { 105 | foreach ($v as $vv) { 106 | $qs .= $k; 107 | $vv = is_bool($vv) ? $castBool($vv) : $vv; 108 | if ($vv !== null) { 109 | $qs .= '='.$encoder((string) $vv); 110 | } 111 | $qs .= '&'; 112 | } 113 | } 114 | } 115 | 116 | return $qs ? (string) substr($qs, 0, -1) : ''; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | assertMethod($method); 43 | if (!($uri instanceof UriInterface)) { 44 | $uri = new Uri($uri); 45 | } 46 | 47 | $this->method = strtoupper($method); 48 | $this->uri = $uri; 49 | $this->setHeaders($headers); 50 | $this->protocol = $version; 51 | 52 | if (!isset($this->headerNames['host'])) { 53 | $this->updateHostFromUri(); 54 | } 55 | 56 | if ($body !== '' && $body !== null) { 57 | $this->stream = Utils::streamFor($body); 58 | } 59 | } 60 | 61 | public function getRequestTarget(): string 62 | { 63 | if ($this->requestTarget !== null) { 64 | return $this->requestTarget; 65 | } 66 | 67 | $target = $this->uri->getPath(); 68 | if ($target === '') { 69 | $target = '/'; 70 | } 71 | if ($this->uri->getQuery() != '') { 72 | $target .= '?'.$this->uri->getQuery(); 73 | } 74 | 75 | return $target; 76 | } 77 | 78 | public function withRequestTarget($requestTarget): RequestInterface 79 | { 80 | if (preg_match('#\s#', $requestTarget)) { 81 | throw new InvalidArgumentException( 82 | 'Invalid request target provided; cannot contain whitespace' 83 | ); 84 | } 85 | 86 | $new = clone $this; 87 | $new->requestTarget = $requestTarget; 88 | 89 | return $new; 90 | } 91 | 92 | public function getMethod(): string 93 | { 94 | return $this->method; 95 | } 96 | 97 | public function withMethod($method): RequestInterface 98 | { 99 | $this->assertMethod($method); 100 | $new = clone $this; 101 | $new->method = strtoupper($method); 102 | 103 | return $new; 104 | } 105 | 106 | public function getUri(): UriInterface 107 | { 108 | return $this->uri; 109 | } 110 | 111 | public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface 112 | { 113 | if ($uri === $this->uri) { 114 | return $this; 115 | } 116 | 117 | $new = clone $this; 118 | $new->uri = $uri; 119 | 120 | if (!$preserveHost || !isset($this->headerNames['host'])) { 121 | $new->updateHostFromUri(); 122 | } 123 | 124 | return $new; 125 | } 126 | 127 | private function updateHostFromUri(): void 128 | { 129 | $host = $this->uri->getHost(); 130 | 131 | if ($host == '') { 132 | return; 133 | } 134 | 135 | if (($port = $this->uri->getPort()) !== null) { 136 | $host .= ':'.$port; 137 | } 138 | 139 | if (isset($this->headerNames['host'])) { 140 | $header = $this->headerNames['host']; 141 | } else { 142 | $header = 'Host'; 143 | $this->headerNames['host'] = 'Host'; 144 | } 145 | // Ensure Host is the first header. 146 | // See: https://datatracker.ietf.org/doc/html/rfc7230#section-5.4 147 | $this->headers = [$header => [$host]] + $this->headers; 148 | } 149 | 150 | /** 151 | * @param mixed $method 152 | */ 153 | private function assertMethod($method): void 154 | { 155 | if (!is_string($method) || $method === '') { 156 | throw new InvalidArgumentException('Method must be a non-empty string.'); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | 'Continue', 20 | 101 => 'Switching Protocols', 21 | 102 => 'Processing', 22 | 200 => 'OK', 23 | 201 => 'Created', 24 | 202 => 'Accepted', 25 | 203 => 'Non-Authoritative Information', 26 | 204 => 'No Content', 27 | 205 => 'Reset Content', 28 | 206 => 'Partial Content', 29 | 207 => 'Multi-status', 30 | 208 => 'Already Reported', 31 | 300 => 'Multiple Choices', 32 | 301 => 'Moved Permanently', 33 | 302 => 'Found', 34 | 303 => 'See Other', 35 | 304 => 'Not Modified', 36 | 305 => 'Use Proxy', 37 | 306 => 'Switch Proxy', 38 | 307 => 'Temporary Redirect', 39 | 308 => 'Permanent Redirect', 40 | 400 => 'Bad Request', 41 | 401 => 'Unauthorized', 42 | 402 => 'Payment Required', 43 | 403 => 'Forbidden', 44 | 404 => 'Not Found', 45 | 405 => 'Method Not Allowed', 46 | 406 => 'Not Acceptable', 47 | 407 => 'Proxy Authentication Required', 48 | 408 => 'Request Time-out', 49 | 409 => 'Conflict', 50 | 410 => 'Gone', 51 | 411 => 'Length Required', 52 | 412 => 'Precondition Failed', 53 | 413 => 'Request Entity Too Large', 54 | 414 => 'Request-URI Too Large', 55 | 415 => 'Unsupported Media Type', 56 | 416 => 'Requested range not satisfiable', 57 | 417 => 'Expectation Failed', 58 | 418 => 'I\'m a teapot', 59 | 422 => 'Unprocessable Entity', 60 | 423 => 'Locked', 61 | 424 => 'Failed Dependency', 62 | 425 => 'Unordered Collection', 63 | 426 => 'Upgrade Required', 64 | 428 => 'Precondition Required', 65 | 429 => 'Too Many Requests', 66 | 431 => 'Request Header Fields Too Large', 67 | 451 => 'Unavailable For Legal Reasons', 68 | 500 => 'Internal Server Error', 69 | 501 => 'Not Implemented', 70 | 502 => 'Bad Gateway', 71 | 503 => 'Service Unavailable', 72 | 504 => 'Gateway Time-out', 73 | 505 => 'HTTP Version not supported', 74 | 506 => 'Variant Also Negotiates', 75 | 507 => 'Insufficient Storage', 76 | 508 => 'Loop Detected', 77 | 510 => 'Not Extended', 78 | 511 => 'Network Authentication Required', 79 | ]; 80 | 81 | /** @var string */ 82 | private $reasonPhrase; 83 | 84 | /** @var int */ 85 | private $statusCode; 86 | 87 | /** 88 | * @param int $status Status code 89 | * @param (string|string[])[] $headers Response headers 90 | * @param string|resource|StreamInterface|null $body Response body 91 | * @param string $version Protocol version 92 | * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) 93 | */ 94 | public function __construct( 95 | int $status = 200, 96 | array $headers = [], 97 | $body = null, 98 | string $version = '1.1', 99 | ?string $reason = null 100 | ) { 101 | $this->assertStatusCodeRange($status); 102 | 103 | $this->statusCode = $status; 104 | 105 | if ($body !== '' && $body !== null) { 106 | $this->stream = Utils::streamFor($body); 107 | } 108 | 109 | $this->setHeaders($headers); 110 | if ($reason == '' && isset(self::PHRASES[$this->statusCode])) { 111 | $this->reasonPhrase = self::PHRASES[$this->statusCode]; 112 | } else { 113 | $this->reasonPhrase = (string) $reason; 114 | } 115 | 116 | $this->protocol = $version; 117 | } 118 | 119 | public function getStatusCode(): int 120 | { 121 | return $this->statusCode; 122 | } 123 | 124 | public function getReasonPhrase(): string 125 | { 126 | return $this->reasonPhrase; 127 | } 128 | 129 | public function withStatus($code, $reasonPhrase = ''): ResponseInterface 130 | { 131 | $this->assertStatusCodeIsInteger($code); 132 | $code = (int) $code; 133 | $this->assertStatusCodeRange($code); 134 | 135 | $new = clone $this; 136 | $new->statusCode = $code; 137 | if ($reasonPhrase == '' && isset(self::PHRASES[$new->statusCode])) { 138 | $reasonPhrase = self::PHRASES[$new->statusCode]; 139 | } 140 | $new->reasonPhrase = (string) $reasonPhrase; 141 | 142 | return $new; 143 | } 144 | 145 | /** 146 | * @param mixed $statusCode 147 | */ 148 | private function assertStatusCodeIsInteger($statusCode): void 149 | { 150 | if (filter_var($statusCode, FILTER_VALIDATE_INT) === false) { 151 | throw new \InvalidArgumentException('Status code must be an integer value.'); 152 | } 153 | } 154 | 155 | private function assertStatusCodeRange(int $statusCode): void 156 | { 157 | if ($statusCode < 100 || $statusCode >= 600) { 158 | throw new \InvalidArgumentException('Status code must be an integer value between 1xx and 5xx.'); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Rfc7230.php: -------------------------------------------------------------------------------- 1 | @,;:\\\"/[\]?={}\x01-\x20\x7F]++):[ \t]*+((?:[ \t]*+[\x21-\x7E\x80-\xFF]++)*+)[ \t]*+\r?\n)m"; 22 | public const HEADER_FOLD_REGEX = "(\r?\n[ \t]++)"; 23 | } 24 | -------------------------------------------------------------------------------- /src/ServerRequest.php: -------------------------------------------------------------------------------- 1 | serverParams = $serverParams; 76 | 77 | parent::__construct($method, $uri, $headers, $body, $version); 78 | } 79 | 80 | /** 81 | * Return an UploadedFile instance array. 82 | * 83 | * @param array $files An array which respect $_FILES structure 84 | * 85 | * @throws InvalidArgumentException for unrecognized values 86 | */ 87 | public static function normalizeFiles(array $files): array 88 | { 89 | $normalized = []; 90 | 91 | foreach ($files as $key => $value) { 92 | if ($value instanceof UploadedFileInterface) { 93 | $normalized[$key] = $value; 94 | } elseif (is_array($value) && isset($value['tmp_name'])) { 95 | $normalized[$key] = self::createUploadedFileFromSpec($value); 96 | } elseif (is_array($value)) { 97 | $normalized[$key] = self::normalizeFiles($value); 98 | continue; 99 | } else { 100 | throw new InvalidArgumentException('Invalid value in files specification'); 101 | } 102 | } 103 | 104 | return $normalized; 105 | } 106 | 107 | /** 108 | * Create and return an UploadedFile instance from a $_FILES specification. 109 | * 110 | * If the specification represents an array of values, this method will 111 | * delegate to normalizeNestedFileSpec() and return that return value. 112 | * 113 | * @param array $value $_FILES struct 114 | * 115 | * @return UploadedFileInterface|UploadedFileInterface[] 116 | */ 117 | private static function createUploadedFileFromSpec(array $value) 118 | { 119 | if (is_array($value['tmp_name'])) { 120 | return self::normalizeNestedFileSpec($value); 121 | } 122 | 123 | return new UploadedFile( 124 | $value['tmp_name'], 125 | (int) $value['size'], 126 | (int) $value['error'], 127 | $value['name'], 128 | $value['type'] 129 | ); 130 | } 131 | 132 | /** 133 | * Normalize an array of file specifications. 134 | * 135 | * Loops through all nested files and returns a normalized array of 136 | * UploadedFileInterface instances. 137 | * 138 | * @return UploadedFileInterface[] 139 | */ 140 | private static function normalizeNestedFileSpec(array $files = []): array 141 | { 142 | $normalizedFiles = []; 143 | 144 | foreach (array_keys($files['tmp_name']) as $key) { 145 | $spec = [ 146 | 'tmp_name' => $files['tmp_name'][$key], 147 | 'size' => $files['size'][$key] ?? null, 148 | 'error' => $files['error'][$key] ?? null, 149 | 'name' => $files['name'][$key] ?? null, 150 | 'type' => $files['type'][$key] ?? null, 151 | ]; 152 | $normalizedFiles[$key] = self::createUploadedFileFromSpec($spec); 153 | } 154 | 155 | return $normalizedFiles; 156 | } 157 | 158 | /** 159 | * Return a ServerRequest populated with superglobals: 160 | * $_GET 161 | * $_POST 162 | * $_COOKIE 163 | * $_FILES 164 | * $_SERVER 165 | */ 166 | public static function fromGlobals(): ServerRequestInterface 167 | { 168 | $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; 169 | $headers = getallheaders(); 170 | $uri = self::getUriFromGlobals(); 171 | $body = new CachingStream(new LazyOpenStream('php://input', 'r+')); 172 | $protocol = isset($_SERVER['SERVER_PROTOCOL']) ? str_replace('HTTP/', '', $_SERVER['SERVER_PROTOCOL']) : '1.1'; 173 | 174 | $serverRequest = new ServerRequest($method, $uri, $headers, $body, $protocol, $_SERVER); 175 | 176 | return $serverRequest 177 | ->withCookieParams($_COOKIE) 178 | ->withQueryParams($_GET) 179 | ->withParsedBody($_POST) 180 | ->withUploadedFiles(self::normalizeFiles($_FILES)); 181 | } 182 | 183 | private static function extractHostAndPortFromAuthority(string $authority): array 184 | { 185 | $uri = 'http://'.$authority; 186 | $parts = parse_url($uri); 187 | if (false === $parts) { 188 | return [null, null]; 189 | } 190 | 191 | $host = $parts['host'] ?? null; 192 | $port = $parts['port'] ?? null; 193 | 194 | return [$host, $port]; 195 | } 196 | 197 | /** 198 | * Get a Uri populated with values from $_SERVER. 199 | */ 200 | public static function getUriFromGlobals(): UriInterface 201 | { 202 | $uri = new Uri(''); 203 | 204 | $uri = $uri->withScheme(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http'); 205 | 206 | $hasPort = false; 207 | if (isset($_SERVER['HTTP_HOST'])) { 208 | [$host, $port] = self::extractHostAndPortFromAuthority($_SERVER['HTTP_HOST']); 209 | if ($host !== null) { 210 | $uri = $uri->withHost($host); 211 | } 212 | 213 | if ($port !== null) { 214 | $hasPort = true; 215 | $uri = $uri->withPort($port); 216 | } 217 | } elseif (isset($_SERVER['SERVER_NAME'])) { 218 | $uri = $uri->withHost($_SERVER['SERVER_NAME']); 219 | } elseif (isset($_SERVER['SERVER_ADDR'])) { 220 | $uri = $uri->withHost($_SERVER['SERVER_ADDR']); 221 | } 222 | 223 | if (!$hasPort && isset($_SERVER['SERVER_PORT'])) { 224 | $uri = $uri->withPort($_SERVER['SERVER_PORT']); 225 | } 226 | 227 | $hasQuery = false; 228 | if (isset($_SERVER['REQUEST_URI'])) { 229 | $requestUriParts = explode('?', $_SERVER['REQUEST_URI'], 2); 230 | $uri = $uri->withPath($requestUriParts[0]); 231 | if (isset($requestUriParts[1])) { 232 | $hasQuery = true; 233 | $uri = $uri->withQuery($requestUriParts[1]); 234 | } 235 | } 236 | 237 | if (!$hasQuery && isset($_SERVER['QUERY_STRING'])) { 238 | $uri = $uri->withQuery($_SERVER['QUERY_STRING']); 239 | } 240 | 241 | return $uri; 242 | } 243 | 244 | public function getServerParams(): array 245 | { 246 | return $this->serverParams; 247 | } 248 | 249 | public function getUploadedFiles(): array 250 | { 251 | return $this->uploadedFiles; 252 | } 253 | 254 | public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface 255 | { 256 | $new = clone $this; 257 | $new->uploadedFiles = $uploadedFiles; 258 | 259 | return $new; 260 | } 261 | 262 | public function getCookieParams(): array 263 | { 264 | return $this->cookieParams; 265 | } 266 | 267 | public function withCookieParams(array $cookies): ServerRequestInterface 268 | { 269 | $new = clone $this; 270 | $new->cookieParams = $cookies; 271 | 272 | return $new; 273 | } 274 | 275 | public function getQueryParams(): array 276 | { 277 | return $this->queryParams; 278 | } 279 | 280 | public function withQueryParams(array $query): ServerRequestInterface 281 | { 282 | $new = clone $this; 283 | $new->queryParams = $query; 284 | 285 | return $new; 286 | } 287 | 288 | /** 289 | * @return array|object|null 290 | */ 291 | public function getParsedBody() 292 | { 293 | return $this->parsedBody; 294 | } 295 | 296 | public function withParsedBody($data): ServerRequestInterface 297 | { 298 | $new = clone $this; 299 | $new->parsedBody = $data; 300 | 301 | return $new; 302 | } 303 | 304 | public function getAttributes(): array 305 | { 306 | return $this->attributes; 307 | } 308 | 309 | /** 310 | * @return mixed 311 | */ 312 | public function getAttribute($attribute, $default = null) 313 | { 314 | if (false === array_key_exists($attribute, $this->attributes)) { 315 | return $default; 316 | } 317 | 318 | return $this->attributes[$attribute]; 319 | } 320 | 321 | public function withAttribute($attribute, $value): ServerRequestInterface 322 | { 323 | $new = clone $this; 324 | $new->attributes[$attribute] = $value; 325 | 326 | return $new; 327 | } 328 | 329 | public function withoutAttribute($attribute): ServerRequestInterface 330 | { 331 | if (false === array_key_exists($attribute, $this->attributes)) { 332 | return $this; 333 | } 334 | 335 | $new = clone $this; 336 | unset($new->attributes[$attribute]); 337 | 338 | return $new; 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/Stream.php: -------------------------------------------------------------------------------- 1 | size = $options['size']; 58 | } 59 | 60 | $this->customMetadata = $options['metadata'] ?? []; 61 | $this->stream = $stream; 62 | $meta = stream_get_meta_data($this->stream); 63 | $this->seekable = $meta['seekable']; 64 | $this->readable = (bool) preg_match(self::READABLE_MODES, $meta['mode']); 65 | $this->writable = (bool) preg_match(self::WRITABLE_MODES, $meta['mode']); 66 | $this->uri = $this->getMetadata('uri'); 67 | } 68 | 69 | /** 70 | * Closes the stream when the destructed 71 | */ 72 | public function __destruct() 73 | { 74 | $this->close(); 75 | } 76 | 77 | public function __toString(): string 78 | { 79 | try { 80 | if ($this->isSeekable()) { 81 | $this->seek(0); 82 | } 83 | 84 | return $this->getContents(); 85 | } catch (\Throwable $e) { 86 | if (\PHP_VERSION_ID >= 70400) { 87 | throw $e; 88 | } 89 | trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); 90 | 91 | return ''; 92 | } 93 | } 94 | 95 | public function getContents(): string 96 | { 97 | if (!isset($this->stream)) { 98 | throw new \RuntimeException('Stream is detached'); 99 | } 100 | 101 | if (!$this->readable) { 102 | throw new \RuntimeException('Cannot read from non-readable stream'); 103 | } 104 | 105 | return Utils::tryGetContents($this->stream); 106 | } 107 | 108 | public function close(): void 109 | { 110 | if (isset($this->stream)) { 111 | if (is_resource($this->stream)) { 112 | fclose($this->stream); 113 | } 114 | $this->detach(); 115 | } 116 | } 117 | 118 | public function detach() 119 | { 120 | if (!isset($this->stream)) { 121 | return null; 122 | } 123 | 124 | $result = $this->stream; 125 | unset($this->stream); 126 | $this->size = $this->uri = null; 127 | $this->readable = $this->writable = $this->seekable = false; 128 | 129 | return $result; 130 | } 131 | 132 | public function getSize(): ?int 133 | { 134 | if ($this->size !== null) { 135 | return $this->size; 136 | } 137 | 138 | if (!isset($this->stream)) { 139 | return null; 140 | } 141 | 142 | // Clear the stat cache if the stream has a URI 143 | if ($this->uri) { 144 | clearstatcache(true, $this->uri); 145 | } 146 | 147 | $stats = fstat($this->stream); 148 | if (is_array($stats) && isset($stats['size'])) { 149 | $this->size = $stats['size']; 150 | 151 | return $this->size; 152 | } 153 | 154 | return null; 155 | } 156 | 157 | public function isReadable(): bool 158 | { 159 | return $this->readable; 160 | } 161 | 162 | public function isWritable(): bool 163 | { 164 | return $this->writable; 165 | } 166 | 167 | public function isSeekable(): bool 168 | { 169 | return $this->seekable; 170 | } 171 | 172 | public function eof(): bool 173 | { 174 | if (!isset($this->stream)) { 175 | throw new \RuntimeException('Stream is detached'); 176 | } 177 | 178 | return feof($this->stream); 179 | } 180 | 181 | public function tell(): int 182 | { 183 | if (!isset($this->stream)) { 184 | throw new \RuntimeException('Stream is detached'); 185 | } 186 | 187 | $result = ftell($this->stream); 188 | 189 | if ($result === false) { 190 | throw new \RuntimeException('Unable to determine stream position'); 191 | } 192 | 193 | return $result; 194 | } 195 | 196 | public function rewind(): void 197 | { 198 | $this->seek(0); 199 | } 200 | 201 | public function seek($offset, $whence = SEEK_SET): void 202 | { 203 | $whence = (int) $whence; 204 | 205 | if (!isset($this->stream)) { 206 | throw new \RuntimeException('Stream is detached'); 207 | } 208 | if (!$this->seekable) { 209 | throw new \RuntimeException('Stream is not seekable'); 210 | } 211 | if (fseek($this->stream, $offset, $whence) === -1) { 212 | throw new \RuntimeException('Unable to seek to stream position ' 213 | .$offset.' with whence '.var_export($whence, true)); 214 | } 215 | } 216 | 217 | public function read($length): string 218 | { 219 | if (!isset($this->stream)) { 220 | throw new \RuntimeException('Stream is detached'); 221 | } 222 | if (!$this->readable) { 223 | throw new \RuntimeException('Cannot read from non-readable stream'); 224 | } 225 | if ($length < 0) { 226 | throw new \RuntimeException('Length parameter cannot be negative'); 227 | } 228 | 229 | if (0 === $length) { 230 | return ''; 231 | } 232 | 233 | try { 234 | $string = fread($this->stream, $length); 235 | } catch (\Exception $e) { 236 | throw new \RuntimeException('Unable to read from stream', 0, $e); 237 | } 238 | 239 | if (false === $string) { 240 | throw new \RuntimeException('Unable to read from stream'); 241 | } 242 | 243 | return $string; 244 | } 245 | 246 | public function write($string): int 247 | { 248 | if (!isset($this->stream)) { 249 | throw new \RuntimeException('Stream is detached'); 250 | } 251 | if (!$this->writable) { 252 | throw new \RuntimeException('Cannot write to a non-writable stream'); 253 | } 254 | 255 | // We can't know the size after writing anything 256 | $this->size = null; 257 | $result = fwrite($this->stream, $string); 258 | 259 | if ($result === false) { 260 | throw new \RuntimeException('Unable to write to stream'); 261 | } 262 | 263 | return $result; 264 | } 265 | 266 | /** 267 | * @return mixed 268 | */ 269 | public function getMetadata($key = null) 270 | { 271 | if (!isset($this->stream)) { 272 | return $key ? null : []; 273 | } elseif (!$key) { 274 | return $this->customMetadata + stream_get_meta_data($this->stream); 275 | } elseif (isset($this->customMetadata[$key])) { 276 | return $this->customMetadata[$key]; 277 | } 278 | 279 | $meta = stream_get_meta_data($this->stream); 280 | 281 | return $meta[$key] ?? null; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/StreamDecoratorTrait.php: -------------------------------------------------------------------------------- 1 | stream = $stream; 22 | } 23 | 24 | /** 25 | * Magic method used to create a new stream if streams are not added in 26 | * the constructor of a decorator (e.g., LazyOpenStream). 27 | * 28 | * @return StreamInterface 29 | */ 30 | public function __get(string $name) 31 | { 32 | if ($name === 'stream') { 33 | $this->stream = $this->createStream(); 34 | 35 | return $this->stream; 36 | } 37 | 38 | throw new \UnexpectedValueException("$name not found on class"); 39 | } 40 | 41 | public function __toString(): string 42 | { 43 | try { 44 | if ($this->isSeekable()) { 45 | $this->seek(0); 46 | } 47 | 48 | return $this->getContents(); 49 | } catch (\Throwable $e) { 50 | if (\PHP_VERSION_ID >= 70400) { 51 | throw $e; 52 | } 53 | trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR); 54 | 55 | return ''; 56 | } 57 | } 58 | 59 | public function getContents(): string 60 | { 61 | return Utils::copyToString($this); 62 | } 63 | 64 | /** 65 | * Allow decorators to implement custom methods 66 | * 67 | * @return mixed 68 | */ 69 | public function __call(string $method, array $args) 70 | { 71 | /** @var callable $callable */ 72 | $callable = [$this->stream, $method]; 73 | $result = ($callable)(...$args); 74 | 75 | // Always return the wrapped object if the result is a return $this 76 | return $result === $this->stream ? $this : $result; 77 | } 78 | 79 | public function close(): void 80 | { 81 | $this->stream->close(); 82 | } 83 | 84 | /** 85 | * @return mixed 86 | */ 87 | public function getMetadata($key = null) 88 | { 89 | return $this->stream->getMetadata($key); 90 | } 91 | 92 | public function detach() 93 | { 94 | return $this->stream->detach(); 95 | } 96 | 97 | public function getSize(): ?int 98 | { 99 | return $this->stream->getSize(); 100 | } 101 | 102 | public function eof(): bool 103 | { 104 | return $this->stream->eof(); 105 | } 106 | 107 | public function tell(): int 108 | { 109 | return $this->stream->tell(); 110 | } 111 | 112 | public function isReadable(): bool 113 | { 114 | return $this->stream->isReadable(); 115 | } 116 | 117 | public function isWritable(): bool 118 | { 119 | return $this->stream->isWritable(); 120 | } 121 | 122 | public function isSeekable(): bool 123 | { 124 | return $this->stream->isSeekable(); 125 | } 126 | 127 | public function rewind(): void 128 | { 129 | $this->seek(0); 130 | } 131 | 132 | public function seek($offset, $whence = SEEK_SET): void 133 | { 134 | $this->stream->seek($offset, $whence); 135 | } 136 | 137 | public function read($length): string 138 | { 139 | return $this->stream->read($length); 140 | } 141 | 142 | public function write($string): int 143 | { 144 | return $this->stream->write($string); 145 | } 146 | 147 | /** 148 | * Implement in subclasses to dynamically create streams when requested. 149 | * 150 | * @throws \BadMethodCallException 151 | */ 152 | protected function createStream(): StreamInterface 153 | { 154 | throw new \BadMethodCallException('Not implemented'); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/StreamWrapper.php: -------------------------------------------------------------------------------- 1 | isReadable()) { 39 | $mode = $stream->isWritable() ? 'r+' : 'r'; 40 | } elseif ($stream->isWritable()) { 41 | $mode = 'w'; 42 | } else { 43 | throw new \InvalidArgumentException('The stream must be readable, ' 44 | .'writable, or both.'); 45 | } 46 | 47 | return fopen('guzzle://stream', $mode, false, self::createStreamContext($stream)); 48 | } 49 | 50 | /** 51 | * Creates a stream context that can be used to open a stream as a php stream resource. 52 | * 53 | * @return resource 54 | */ 55 | public static function createStreamContext(StreamInterface $stream) 56 | { 57 | return stream_context_create([ 58 | 'guzzle' => ['stream' => $stream], 59 | ]); 60 | } 61 | 62 | /** 63 | * Registers the stream wrapper if needed 64 | */ 65 | public static function register(): void 66 | { 67 | if (!in_array('guzzle', stream_get_wrappers())) { 68 | stream_wrapper_register('guzzle', __CLASS__); 69 | } 70 | } 71 | 72 | public function stream_open(string $path, string $mode, int $options, ?string &$opened_path = null): bool 73 | { 74 | $options = stream_context_get_options($this->context); 75 | 76 | if (!isset($options['guzzle']['stream'])) { 77 | return false; 78 | } 79 | 80 | $this->mode = $mode; 81 | $this->stream = $options['guzzle']['stream']; 82 | 83 | return true; 84 | } 85 | 86 | public function stream_read(int $count): string 87 | { 88 | return $this->stream->read($count); 89 | } 90 | 91 | public function stream_write(string $data): int 92 | { 93 | return $this->stream->write($data); 94 | } 95 | 96 | public function stream_tell(): int 97 | { 98 | return $this->stream->tell(); 99 | } 100 | 101 | public function stream_eof(): bool 102 | { 103 | return $this->stream->eof(); 104 | } 105 | 106 | public function stream_seek(int $offset, int $whence): bool 107 | { 108 | $this->stream->seek($offset, $whence); 109 | 110 | return true; 111 | } 112 | 113 | /** 114 | * @return resource|false 115 | */ 116 | public function stream_cast(int $cast_as) 117 | { 118 | $stream = clone $this->stream; 119 | $resource = $stream->detach(); 120 | 121 | return $resource ?? false; 122 | } 123 | 124 | /** 125 | * @return array{ 126 | * dev: int, 127 | * ino: int, 128 | * mode: int, 129 | * nlink: int, 130 | * uid: int, 131 | * gid: int, 132 | * rdev: int, 133 | * size: int, 134 | * atime: int, 135 | * mtime: int, 136 | * ctime: int, 137 | * blksize: int, 138 | * blocks: int 139 | * }|false 140 | */ 141 | public function stream_stat() 142 | { 143 | if ($this->stream->getSize() === null) { 144 | return false; 145 | } 146 | 147 | static $modeMap = [ 148 | 'r' => 33060, 149 | 'rb' => 33060, 150 | 'r+' => 33206, 151 | 'w' => 33188, 152 | 'wb' => 33188, 153 | ]; 154 | 155 | return [ 156 | 'dev' => 0, 157 | 'ino' => 0, 158 | 'mode' => $modeMap[$this->mode], 159 | 'nlink' => 0, 160 | 'uid' => 0, 161 | 'gid' => 0, 162 | 'rdev' => 0, 163 | 'size' => $this->stream->getSize() ?: 0, 164 | 'atime' => 0, 165 | 'mtime' => 0, 166 | 'ctime' => 0, 167 | 'blksize' => 0, 168 | 'blocks' => 0, 169 | ]; 170 | } 171 | 172 | /** 173 | * @return array{ 174 | * dev: int, 175 | * ino: int, 176 | * mode: int, 177 | * nlink: int, 178 | * uid: int, 179 | * gid: int, 180 | * rdev: int, 181 | * size: int, 182 | * atime: int, 183 | * mtime: int, 184 | * ctime: int, 185 | * blksize: int, 186 | * blocks: int 187 | * } 188 | */ 189 | public function url_stat(string $path, int $flags): array 190 | { 191 | return [ 192 | 'dev' => 0, 193 | 'ino' => 0, 194 | 'mode' => 0, 195 | 'nlink' => 0, 196 | 'uid' => 0, 197 | 'gid' => 0, 198 | 'rdev' => 0, 199 | 'size' => 0, 200 | 'atime' => 0, 201 | 'mtime' => 0, 202 | 'ctime' => 0, 203 | 'blksize' => 0, 204 | 'blocks' => 0, 205 | ]; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/UploadedFile.php: -------------------------------------------------------------------------------- 1 | 'UPLOAD_ERR_OK', 16 | UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE', 17 | UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE', 18 | UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL', 19 | UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE', 20 | UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR', 21 | UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE', 22 | UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION', 23 | ]; 24 | 25 | /** 26 | * @var string|null 27 | */ 28 | private $clientFilename; 29 | 30 | /** 31 | * @var string|null 32 | */ 33 | private $clientMediaType; 34 | 35 | /** 36 | * @var int 37 | */ 38 | private $error; 39 | 40 | /** 41 | * @var string|null 42 | */ 43 | private $file; 44 | 45 | /** 46 | * @var bool 47 | */ 48 | private $moved = false; 49 | 50 | /** 51 | * @var int|null 52 | */ 53 | private $size; 54 | 55 | /** 56 | * @var StreamInterface|null 57 | */ 58 | private $stream; 59 | 60 | /** 61 | * @param StreamInterface|string|resource $streamOrFile 62 | */ 63 | public function __construct( 64 | $streamOrFile, 65 | ?int $size, 66 | int $errorStatus, 67 | ?string $clientFilename = null, 68 | ?string $clientMediaType = null 69 | ) { 70 | $this->setError($errorStatus); 71 | $this->size = $size; 72 | $this->clientFilename = $clientFilename; 73 | $this->clientMediaType = $clientMediaType; 74 | 75 | if ($this->isOk()) { 76 | $this->setStreamOrFile($streamOrFile); 77 | } 78 | } 79 | 80 | /** 81 | * Depending on the value set file or stream variable 82 | * 83 | * @param StreamInterface|string|resource $streamOrFile 84 | * 85 | * @throws InvalidArgumentException 86 | */ 87 | private function setStreamOrFile($streamOrFile): void 88 | { 89 | if (is_string($streamOrFile)) { 90 | $this->file = $streamOrFile; 91 | } elseif (is_resource($streamOrFile)) { 92 | $this->stream = new Stream($streamOrFile); 93 | } elseif ($streamOrFile instanceof StreamInterface) { 94 | $this->stream = $streamOrFile; 95 | } else { 96 | throw new InvalidArgumentException( 97 | 'Invalid stream or file provided for UploadedFile' 98 | ); 99 | } 100 | } 101 | 102 | /** 103 | * @throws InvalidArgumentException 104 | */ 105 | private function setError(int $error): void 106 | { 107 | if (!isset(UploadedFile::ERROR_MAP[$error])) { 108 | throw new InvalidArgumentException( 109 | 'Invalid error status for UploadedFile' 110 | ); 111 | } 112 | 113 | $this->error = $error; 114 | } 115 | 116 | private static function isStringNotEmpty($param): bool 117 | { 118 | return is_string($param) && false === empty($param); 119 | } 120 | 121 | /** 122 | * Return true if there is no upload error 123 | */ 124 | private function isOk(): bool 125 | { 126 | return $this->error === UPLOAD_ERR_OK; 127 | } 128 | 129 | public function isMoved(): bool 130 | { 131 | return $this->moved; 132 | } 133 | 134 | /** 135 | * @throws RuntimeException if is moved or not ok 136 | */ 137 | private function validateActive(): void 138 | { 139 | if (false === $this->isOk()) { 140 | throw new RuntimeException(\sprintf('Cannot retrieve stream due to upload error (%s)', self::ERROR_MAP[$this->error])); 141 | } 142 | 143 | if ($this->isMoved()) { 144 | throw new RuntimeException('Cannot retrieve stream after it has already been moved'); 145 | } 146 | } 147 | 148 | public function getStream(): StreamInterface 149 | { 150 | $this->validateActive(); 151 | 152 | if ($this->stream instanceof StreamInterface) { 153 | return $this->stream; 154 | } 155 | 156 | /** @var string $file */ 157 | $file = $this->file; 158 | 159 | return new LazyOpenStream($file, 'r+'); 160 | } 161 | 162 | public function moveTo($targetPath): void 163 | { 164 | $this->validateActive(); 165 | 166 | if (false === self::isStringNotEmpty($targetPath)) { 167 | throw new InvalidArgumentException( 168 | 'Invalid path provided for move operation; must be a non-empty string' 169 | ); 170 | } 171 | 172 | if ($this->file) { 173 | $this->moved = PHP_SAPI === 'cli' 174 | ? rename($this->file, $targetPath) 175 | : move_uploaded_file($this->file, $targetPath); 176 | } else { 177 | Utils::copyToStream( 178 | $this->getStream(), 179 | new LazyOpenStream($targetPath, 'w') 180 | ); 181 | 182 | $this->moved = true; 183 | } 184 | 185 | if (false === $this->moved) { 186 | throw new RuntimeException( 187 | sprintf('Uploaded file could not be moved to %s', $targetPath) 188 | ); 189 | } 190 | } 191 | 192 | public function getSize(): ?int 193 | { 194 | return $this->size; 195 | } 196 | 197 | public function getError(): int 198 | { 199 | return $this->error; 200 | } 201 | 202 | public function getClientFilename(): ?string 203 | { 204 | return $this->clientFilename; 205 | } 206 | 207 | public function getClientMediaType(): ?string 208 | { 209 | return $this->clientMediaType; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Uri.php: -------------------------------------------------------------------------------- 1 | 80, 29 | 'https' => 443, 30 | 'ftp' => 21, 31 | 'gopher' => 70, 32 | 'nntp' => 119, 33 | 'news' => 119, 34 | 'telnet' => 23, 35 | 'tn3270' => 23, 36 | 'imap' => 143, 37 | 'pop' => 110, 38 | 'ldap' => 389, 39 | ]; 40 | 41 | /** 42 | * Unreserved characters for use in a regex. 43 | * 44 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 45 | */ 46 | private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; 47 | 48 | /** 49 | * Sub-delims for use in a regex. 50 | * 51 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 52 | */ 53 | private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; 54 | private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26']; 55 | 56 | /** @var string Uri scheme. */ 57 | private $scheme = ''; 58 | 59 | /** @var string Uri user info. */ 60 | private $userInfo = ''; 61 | 62 | /** @var string Uri host. */ 63 | private $host = ''; 64 | 65 | /** @var int|null Uri port. */ 66 | private $port; 67 | 68 | /** @var string Uri path. */ 69 | private $path = ''; 70 | 71 | /** @var string Uri query string. */ 72 | private $query = ''; 73 | 74 | /** @var string Uri fragment. */ 75 | private $fragment = ''; 76 | 77 | /** @var string|null String representation */ 78 | private $composedComponents; 79 | 80 | public function __construct(string $uri = '') 81 | { 82 | if ($uri !== '') { 83 | $parts = self::parse($uri); 84 | if ($parts === false) { 85 | throw new MalformedUriException("Unable to parse URI: $uri"); 86 | } 87 | $this->applyParts($parts); 88 | } 89 | } 90 | 91 | /** 92 | * UTF-8 aware \parse_url() replacement. 93 | * 94 | * The internal function produces broken output for non ASCII domain names 95 | * (IDN) when used with locales other than "C". 96 | * 97 | * On the other hand, cURL understands IDN correctly only when UTF-8 locale 98 | * is configured ("C.UTF-8", "en_US.UTF-8", etc.). 99 | * 100 | * @see https://bugs.php.net/bug.php?id=52923 101 | * @see https://www.php.net/manual/en/function.parse-url.php#114817 102 | * @see https://curl.haxx.se/libcurl/c/CURLOPT_URL.html#ENCODING 103 | * 104 | * @return array|false 105 | */ 106 | private static function parse(string $url) 107 | { 108 | // If IPv6 109 | $prefix = ''; 110 | if (preg_match('%^(.*://\[[0-9:a-fA-F]+\])(.*?)$%', $url, $matches)) { 111 | /** @var array{0:string, 1:string, 2:string} $matches */ 112 | $prefix = $matches[1]; 113 | $url = $matches[2]; 114 | } 115 | 116 | /** @var string */ 117 | $encodedUrl = preg_replace_callback( 118 | '%[^:/@?&=#]+%usD', 119 | static function ($matches) { 120 | return urlencode($matches[0]); 121 | }, 122 | $url 123 | ); 124 | 125 | $result = parse_url($prefix.$encodedUrl); 126 | 127 | if ($result === false) { 128 | return false; 129 | } 130 | 131 | return array_map('urldecode', $result); 132 | } 133 | 134 | public function __toString(): string 135 | { 136 | if ($this->composedComponents === null) { 137 | $this->composedComponents = self::composeComponents( 138 | $this->scheme, 139 | $this->getAuthority(), 140 | $this->path, 141 | $this->query, 142 | $this->fragment 143 | ); 144 | } 145 | 146 | return $this->composedComponents; 147 | } 148 | 149 | /** 150 | * Composes a URI reference string from its various components. 151 | * 152 | * Usually this method does not need to be called manually but instead is used indirectly via 153 | * `Psr\Http\Message\UriInterface::__toString`. 154 | * 155 | * PSR-7 UriInterface treats an empty component the same as a missing component as 156 | * getQuery(), getFragment() etc. always return a string. This explains the slight 157 | * difference to RFC 3986 Section 5.3. 158 | * 159 | * Another adjustment is that the authority separator is added even when the authority is missing/empty 160 | * for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with 161 | * `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But 162 | * `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to 163 | * that format). 164 | * 165 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-5.3 166 | */ 167 | public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string 168 | { 169 | $uri = ''; 170 | 171 | // weak type checks to also accept null until we can add scalar type hints 172 | if ($scheme != '') { 173 | $uri .= $scheme.':'; 174 | } 175 | 176 | if ($authority != '' || $scheme === 'file') { 177 | $uri .= '//'.$authority; 178 | } 179 | 180 | if ($authority != '' && $path != '' && $path[0] != '/') { 181 | $path = '/'.$path; 182 | } 183 | 184 | $uri .= $path; 185 | 186 | if ($query != '') { 187 | $uri .= '?'.$query; 188 | } 189 | 190 | if ($fragment != '') { 191 | $uri .= '#'.$fragment; 192 | } 193 | 194 | return $uri; 195 | } 196 | 197 | /** 198 | * Whether the URI has the default port of the current scheme. 199 | * 200 | * `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used 201 | * independently of the implementation. 202 | */ 203 | public static function isDefaultPort(UriInterface $uri): bool 204 | { 205 | return $uri->getPort() === null 206 | || (isset(self::DEFAULT_PORTS[$uri->getScheme()]) && $uri->getPort() === self::DEFAULT_PORTS[$uri->getScheme()]); 207 | } 208 | 209 | /** 210 | * Whether the URI is absolute, i.e. it has a scheme. 211 | * 212 | * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true 213 | * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative 214 | * to another URI, the base URI. Relative references can be divided into several forms: 215 | * - network-path references, e.g. '//example.com/path' 216 | * - absolute-path references, e.g. '/path' 217 | * - relative-path references, e.g. 'subpath' 218 | * 219 | * @see Uri::isNetworkPathReference 220 | * @see Uri::isAbsolutePathReference 221 | * @see Uri::isRelativePathReference 222 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4 223 | */ 224 | public static function isAbsolute(UriInterface $uri): bool 225 | { 226 | return $uri->getScheme() !== ''; 227 | } 228 | 229 | /** 230 | * Whether the URI is a network-path reference. 231 | * 232 | * A relative reference that begins with two slash characters is termed an network-path reference. 233 | * 234 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 235 | */ 236 | public static function isNetworkPathReference(UriInterface $uri): bool 237 | { 238 | return $uri->getScheme() === '' && $uri->getAuthority() !== ''; 239 | } 240 | 241 | /** 242 | * Whether the URI is a absolute-path reference. 243 | * 244 | * A relative reference that begins with a single slash character is termed an absolute-path reference. 245 | * 246 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 247 | */ 248 | public static function isAbsolutePathReference(UriInterface $uri): bool 249 | { 250 | return $uri->getScheme() === '' 251 | && $uri->getAuthority() === '' 252 | && isset($uri->getPath()[0]) 253 | && $uri->getPath()[0] === '/'; 254 | } 255 | 256 | /** 257 | * Whether the URI is a relative-path reference. 258 | * 259 | * A relative reference that does not begin with a slash character is termed a relative-path reference. 260 | * 261 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.2 262 | */ 263 | public static function isRelativePathReference(UriInterface $uri): bool 264 | { 265 | return $uri->getScheme() === '' 266 | && $uri->getAuthority() === '' 267 | && (!isset($uri->getPath()[0]) || $uri->getPath()[0] !== '/'); 268 | } 269 | 270 | /** 271 | * Whether the URI is a same-document reference. 272 | * 273 | * A same-document reference refers to a URI that is, aside from its fragment 274 | * component, identical to the base URI. When no base URI is given, only an empty 275 | * URI reference (apart from its fragment) is considered a same-document reference. 276 | * 277 | * @param UriInterface $uri The URI to check 278 | * @param UriInterface|null $base An optional base URI to compare against 279 | * 280 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.4 281 | */ 282 | public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool 283 | { 284 | if ($base !== null) { 285 | $uri = UriResolver::resolve($base, $uri); 286 | 287 | return ($uri->getScheme() === $base->getScheme()) 288 | && ($uri->getAuthority() === $base->getAuthority()) 289 | && ($uri->getPath() === $base->getPath()) 290 | && ($uri->getQuery() === $base->getQuery()); 291 | } 292 | 293 | return $uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '' && $uri->getQuery() === ''; 294 | } 295 | 296 | /** 297 | * Creates a new URI with a specific query string value removed. 298 | * 299 | * Any existing query string values that exactly match the provided key are 300 | * removed. 301 | * 302 | * @param UriInterface $uri URI to use as a base. 303 | * @param string $key Query string key to remove. 304 | */ 305 | public static function withoutQueryValue(UriInterface $uri, string $key): UriInterface 306 | { 307 | $result = self::getFilteredQueryString($uri, [$key]); 308 | 309 | return $uri->withQuery(implode('&', $result)); 310 | } 311 | 312 | /** 313 | * Creates a new URI with a specific query string value. 314 | * 315 | * Any existing query string values that exactly match the provided key are 316 | * removed and replaced with the given key value pair. 317 | * 318 | * A value of null will set the query string key without a value, e.g. "key" 319 | * instead of "key=value". 320 | * 321 | * @param UriInterface $uri URI to use as a base. 322 | * @param string $key Key to set. 323 | * @param string|null $value Value to set 324 | */ 325 | public static function withQueryValue(UriInterface $uri, string $key, ?string $value): UriInterface 326 | { 327 | $result = self::getFilteredQueryString($uri, [$key]); 328 | 329 | $result[] = self::generateQueryString($key, $value); 330 | 331 | return $uri->withQuery(implode('&', $result)); 332 | } 333 | 334 | /** 335 | * Creates a new URI with multiple specific query string values. 336 | * 337 | * It has the same behavior as withQueryValue() but for an associative array of key => value. 338 | * 339 | * @param UriInterface $uri URI to use as a base. 340 | * @param (string|null)[] $keyValueArray Associative array of key and values 341 | */ 342 | public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface 343 | { 344 | $result = self::getFilteredQueryString($uri, array_keys($keyValueArray)); 345 | 346 | foreach ($keyValueArray as $key => $value) { 347 | $result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null); 348 | } 349 | 350 | return $uri->withQuery(implode('&', $result)); 351 | } 352 | 353 | /** 354 | * Creates a URI from a hash of `parse_url` components. 355 | * 356 | * @see https://www.php.net/manual/en/function.parse-url.php 357 | * 358 | * @throws MalformedUriException If the components do not form a valid URI. 359 | */ 360 | public static function fromParts(array $parts): UriInterface 361 | { 362 | $uri = new self(); 363 | $uri->applyParts($parts); 364 | $uri->validateState(); 365 | 366 | return $uri; 367 | } 368 | 369 | public function getScheme(): string 370 | { 371 | return $this->scheme; 372 | } 373 | 374 | public function getAuthority(): string 375 | { 376 | $authority = $this->host; 377 | if ($this->userInfo !== '') { 378 | $authority = $this->userInfo.'@'.$authority; 379 | } 380 | 381 | if ($this->port !== null) { 382 | $authority .= ':'.$this->port; 383 | } 384 | 385 | return $authority; 386 | } 387 | 388 | public function getUserInfo(): string 389 | { 390 | return $this->userInfo; 391 | } 392 | 393 | public function getHost(): string 394 | { 395 | return $this->host; 396 | } 397 | 398 | public function getPort(): ?int 399 | { 400 | return $this->port; 401 | } 402 | 403 | public function getPath(): string 404 | { 405 | return $this->path; 406 | } 407 | 408 | public function getQuery(): string 409 | { 410 | return $this->query; 411 | } 412 | 413 | public function getFragment(): string 414 | { 415 | return $this->fragment; 416 | } 417 | 418 | public function withScheme($scheme): UriInterface 419 | { 420 | $scheme = $this->filterScheme($scheme); 421 | 422 | if ($this->scheme === $scheme) { 423 | return $this; 424 | } 425 | 426 | $new = clone $this; 427 | $new->scheme = $scheme; 428 | $new->composedComponents = null; 429 | $new->removeDefaultPort(); 430 | $new->validateState(); 431 | 432 | return $new; 433 | } 434 | 435 | public function withUserInfo($user, $password = null): UriInterface 436 | { 437 | $info = $this->filterUserInfoComponent($user); 438 | if ($password !== null) { 439 | $info .= ':'.$this->filterUserInfoComponent($password); 440 | } 441 | 442 | if ($this->userInfo === $info) { 443 | return $this; 444 | } 445 | 446 | $new = clone $this; 447 | $new->userInfo = $info; 448 | $new->composedComponents = null; 449 | $new->validateState(); 450 | 451 | return $new; 452 | } 453 | 454 | public function withHost($host): UriInterface 455 | { 456 | $host = $this->filterHost($host); 457 | 458 | if ($this->host === $host) { 459 | return $this; 460 | } 461 | 462 | $new = clone $this; 463 | $new->host = $host; 464 | $new->composedComponents = null; 465 | $new->validateState(); 466 | 467 | return $new; 468 | } 469 | 470 | public function withPort($port): UriInterface 471 | { 472 | $port = $this->filterPort($port); 473 | 474 | if ($this->port === $port) { 475 | return $this; 476 | } 477 | 478 | $new = clone $this; 479 | $new->port = $port; 480 | $new->composedComponents = null; 481 | $new->removeDefaultPort(); 482 | $new->validateState(); 483 | 484 | return $new; 485 | } 486 | 487 | public function withPath($path): UriInterface 488 | { 489 | $path = $this->filterPath($path); 490 | 491 | if ($this->path === $path) { 492 | return $this; 493 | } 494 | 495 | $new = clone $this; 496 | $new->path = $path; 497 | $new->composedComponents = null; 498 | $new->validateState(); 499 | 500 | return $new; 501 | } 502 | 503 | public function withQuery($query): UriInterface 504 | { 505 | $query = $this->filterQueryAndFragment($query); 506 | 507 | if ($this->query === $query) { 508 | return $this; 509 | } 510 | 511 | $new = clone $this; 512 | $new->query = $query; 513 | $new->composedComponents = null; 514 | 515 | return $new; 516 | } 517 | 518 | public function withFragment($fragment): UriInterface 519 | { 520 | $fragment = $this->filterQueryAndFragment($fragment); 521 | 522 | if ($this->fragment === $fragment) { 523 | return $this; 524 | } 525 | 526 | $new = clone $this; 527 | $new->fragment = $fragment; 528 | $new->composedComponents = null; 529 | 530 | return $new; 531 | } 532 | 533 | public function jsonSerialize(): string 534 | { 535 | return $this->__toString(); 536 | } 537 | 538 | /** 539 | * Apply parse_url parts to a URI. 540 | * 541 | * @param array $parts Array of parse_url parts to apply. 542 | */ 543 | private function applyParts(array $parts): void 544 | { 545 | $this->scheme = isset($parts['scheme']) 546 | ? $this->filterScheme($parts['scheme']) 547 | : ''; 548 | $this->userInfo = isset($parts['user']) 549 | ? $this->filterUserInfoComponent($parts['user']) 550 | : ''; 551 | $this->host = isset($parts['host']) 552 | ? $this->filterHost($parts['host']) 553 | : ''; 554 | $this->port = isset($parts['port']) 555 | ? $this->filterPort($parts['port']) 556 | : null; 557 | $this->path = isset($parts['path']) 558 | ? $this->filterPath($parts['path']) 559 | : ''; 560 | $this->query = isset($parts['query']) 561 | ? $this->filterQueryAndFragment($parts['query']) 562 | : ''; 563 | $this->fragment = isset($parts['fragment']) 564 | ? $this->filterQueryAndFragment($parts['fragment']) 565 | : ''; 566 | if (isset($parts['pass'])) { 567 | $this->userInfo .= ':'.$this->filterUserInfoComponent($parts['pass']); 568 | } 569 | 570 | $this->removeDefaultPort(); 571 | } 572 | 573 | /** 574 | * @param mixed $scheme 575 | * 576 | * @throws \InvalidArgumentException If the scheme is invalid. 577 | */ 578 | private function filterScheme($scheme): string 579 | { 580 | if (!is_string($scheme)) { 581 | throw new \InvalidArgumentException('Scheme must be a string'); 582 | } 583 | 584 | return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); 585 | } 586 | 587 | /** 588 | * @param mixed $component 589 | * 590 | * @throws \InvalidArgumentException If the user info is invalid. 591 | */ 592 | private function filterUserInfoComponent($component): string 593 | { 594 | if (!is_string($component)) { 595 | throw new \InvalidArgumentException('User info must be a string'); 596 | } 597 | 598 | return preg_replace_callback( 599 | '/(?:[^%'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.']+|%(?![A-Fa-f0-9]{2}))/', 600 | [$this, 'rawurlencodeMatchZero'], 601 | $component 602 | ); 603 | } 604 | 605 | /** 606 | * @param mixed $host 607 | * 608 | * @throws \InvalidArgumentException If the host is invalid. 609 | */ 610 | private function filterHost($host): string 611 | { 612 | if (!is_string($host)) { 613 | throw new \InvalidArgumentException('Host must be a string'); 614 | } 615 | 616 | return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); 617 | } 618 | 619 | /** 620 | * @param mixed $port 621 | * 622 | * @throws \InvalidArgumentException If the port is invalid. 623 | */ 624 | private function filterPort($port): ?int 625 | { 626 | if ($port === null) { 627 | return null; 628 | } 629 | 630 | $port = (int) $port; 631 | if (0 > $port || 0xFFFF < $port) { 632 | throw new \InvalidArgumentException( 633 | sprintf('Invalid port: %d. Must be between 0 and 65535', $port) 634 | ); 635 | } 636 | 637 | return $port; 638 | } 639 | 640 | /** 641 | * @param (string|int)[] $keys 642 | * 643 | * @return string[] 644 | */ 645 | private static function getFilteredQueryString(UriInterface $uri, array $keys): array 646 | { 647 | $current = $uri->getQuery(); 648 | 649 | if ($current === '') { 650 | return []; 651 | } 652 | 653 | $decodedKeys = array_map(function ($k): string { 654 | return rawurldecode((string) $k); 655 | }, $keys); 656 | 657 | return array_filter(explode('&', $current), function ($part) use ($decodedKeys) { 658 | return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true); 659 | }); 660 | } 661 | 662 | private static function generateQueryString(string $key, ?string $value): string 663 | { 664 | // Query string separators ("=", "&") within the key or value need to be encoded 665 | // (while preventing double-encoding) before setting the query string. All other 666 | // chars that need percent-encoding will be encoded by withQuery(). 667 | $queryString = strtr($key, self::QUERY_SEPARATORS_REPLACEMENT); 668 | 669 | if ($value !== null) { 670 | $queryString .= '='.strtr($value, self::QUERY_SEPARATORS_REPLACEMENT); 671 | } 672 | 673 | return $queryString; 674 | } 675 | 676 | private function removeDefaultPort(): void 677 | { 678 | if ($this->port !== null && self::isDefaultPort($this)) { 679 | $this->port = null; 680 | } 681 | } 682 | 683 | /** 684 | * Filters the path of a URI 685 | * 686 | * @param mixed $path 687 | * 688 | * @throws \InvalidArgumentException If the path is invalid. 689 | */ 690 | private function filterPath($path): string 691 | { 692 | if (!is_string($path)) { 693 | throw new \InvalidArgumentException('Path must be a string'); 694 | } 695 | 696 | return preg_replace_callback( 697 | '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/]++|%(?![A-Fa-f0-9]{2}))/', 698 | [$this, 'rawurlencodeMatchZero'], 699 | $path 700 | ); 701 | } 702 | 703 | /** 704 | * Filters the query string or fragment of a URI. 705 | * 706 | * @param mixed $str 707 | * 708 | * @throws \InvalidArgumentException If the query or fragment is invalid. 709 | */ 710 | private function filterQueryAndFragment($str): string 711 | { 712 | if (!is_string($str)) { 713 | throw new \InvalidArgumentException('Query and fragment must be a string'); 714 | } 715 | 716 | return preg_replace_callback( 717 | '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', 718 | [$this, 'rawurlencodeMatchZero'], 719 | $str 720 | ); 721 | } 722 | 723 | private function rawurlencodeMatchZero(array $match): string 724 | { 725 | return rawurlencode($match[0]); 726 | } 727 | 728 | private function validateState(): void 729 | { 730 | if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { 731 | $this->host = self::HTTP_DEFAULT_HOST; 732 | } 733 | 734 | if ($this->getAuthority() === '') { 735 | if (0 === strpos($this->path, '//')) { 736 | throw new MalformedUriException('The path of a URI without an authority must not start with two slashes "//"'); 737 | } 738 | if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) { 739 | throw new MalformedUriException('A relative URI must not have a path beginning with a segment containing a colon'); 740 | } 741 | } 742 | } 743 | } 744 | -------------------------------------------------------------------------------- /src/UriComparator.php: -------------------------------------------------------------------------------- 1 | getHost(), $modified->getHost()) !== 0) { 23 | return true; 24 | } 25 | 26 | if ($original->getScheme() !== $modified->getScheme()) { 27 | return true; 28 | } 29 | 30 | if (self::computePort($original) !== self::computePort($modified)) { 31 | return true; 32 | } 33 | 34 | return false; 35 | } 36 | 37 | private static function computePort(UriInterface $uri): int 38 | { 39 | $port = $uri->getPort(); 40 | 41 | if (null !== $port) { 42 | return $port; 43 | } 44 | 45 | return 'https' === $uri->getScheme() ? 443 : 80; 46 | } 47 | 48 | private function __construct() 49 | { 50 | // cannot be instantiated 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/UriNormalizer.php: -------------------------------------------------------------------------------- 1 | getPath() === '' 135 | && ($uri->getScheme() === 'http' || $uri->getScheme() === 'https') 136 | ) { 137 | $uri = $uri->withPath('/'); 138 | } 139 | 140 | if ($flags & self::REMOVE_DEFAULT_HOST && $uri->getScheme() === 'file' && $uri->getHost() === 'localhost') { 141 | $uri = $uri->withHost(''); 142 | } 143 | 144 | if ($flags & self::REMOVE_DEFAULT_PORT && $uri->getPort() !== null && Uri::isDefaultPort($uri)) { 145 | $uri = $uri->withPort(null); 146 | } 147 | 148 | if ($flags & self::REMOVE_DOT_SEGMENTS && !Uri::isRelativePathReference($uri)) { 149 | $uri = $uri->withPath(UriResolver::removeDotSegments($uri->getPath())); 150 | } 151 | 152 | if ($flags & self::REMOVE_DUPLICATE_SLASHES) { 153 | $uri = $uri->withPath(preg_replace('#//++#', '/', $uri->getPath())); 154 | } 155 | 156 | if ($flags & self::SORT_QUERY_PARAMETERS && $uri->getQuery() !== '') { 157 | $queryKeyValues = explode('&', $uri->getQuery()); 158 | sort($queryKeyValues); 159 | $uri = $uri->withQuery(implode('&', $queryKeyValues)); 160 | } 161 | 162 | return $uri; 163 | } 164 | 165 | /** 166 | * Whether two URIs can be considered equivalent. 167 | * 168 | * Both URIs are normalized automatically before comparison with the given $normalizations bitmask. The method also 169 | * accepts relative URI references and returns true when they are equivalent. This of course assumes they will be 170 | * resolved against the same base URI. If this is not the case, determination of equivalence or difference of 171 | * relative references does not mean anything. 172 | * 173 | * @param UriInterface $uri1 An URI to compare 174 | * @param UriInterface $uri2 An URI to compare 175 | * @param int $normalizations A bitmask of normalizations to apply, see constants 176 | * 177 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-6.1 178 | */ 179 | public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, int $normalizations = self::PRESERVING_NORMALIZATIONS): bool 180 | { 181 | return (string) self::normalize($uri1, $normalizations) === (string) self::normalize($uri2, $normalizations); 182 | } 183 | 184 | private static function capitalizePercentEncoding(UriInterface $uri): UriInterface 185 | { 186 | $regex = '/(?:%[A-Fa-f0-9]{2})++/'; 187 | 188 | $callback = function (array $match): string { 189 | return strtoupper($match[0]); 190 | }; 191 | 192 | return 193 | $uri->withPath( 194 | preg_replace_callback($regex, $callback, $uri->getPath()) 195 | )->withQuery( 196 | preg_replace_callback($regex, $callback, $uri->getQuery()) 197 | ); 198 | } 199 | 200 | private static function decodeUnreservedCharacters(UriInterface $uri): UriInterface 201 | { 202 | $regex = '/%(?:2D|2E|5F|7E|3[0-9]|[46][1-9A-F]|[57][0-9A])/i'; 203 | 204 | $callback = function (array $match): string { 205 | return rawurldecode($match[0]); 206 | }; 207 | 208 | return 209 | $uri->withPath( 210 | preg_replace_callback($regex, $callback, $uri->getPath()) 211 | )->withQuery( 212 | preg_replace_callback($regex, $callback, $uri->getQuery()) 213 | ); 214 | } 215 | 216 | private function __construct() 217 | { 218 | // cannot be instantiated 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/UriResolver.php: -------------------------------------------------------------------------------- 1 | getScheme() != '') { 66 | return $rel->withPath(self::removeDotSegments($rel->getPath())); 67 | } 68 | 69 | if ($rel->getAuthority() != '') { 70 | $targetAuthority = $rel->getAuthority(); 71 | $targetPath = self::removeDotSegments($rel->getPath()); 72 | $targetQuery = $rel->getQuery(); 73 | } else { 74 | $targetAuthority = $base->getAuthority(); 75 | if ($rel->getPath() === '') { 76 | $targetPath = $base->getPath(); 77 | $targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery(); 78 | } else { 79 | if ($rel->getPath()[0] === '/') { 80 | $targetPath = $rel->getPath(); 81 | } else { 82 | if ($targetAuthority != '' && $base->getPath() === '') { 83 | $targetPath = '/'.$rel->getPath(); 84 | } else { 85 | $lastSlashPos = strrpos($base->getPath(), '/'); 86 | if ($lastSlashPos === false) { 87 | $targetPath = $rel->getPath(); 88 | } else { 89 | $targetPath = substr($base->getPath(), 0, $lastSlashPos + 1).$rel->getPath(); 90 | } 91 | } 92 | } 93 | $targetPath = self::removeDotSegments($targetPath); 94 | $targetQuery = $rel->getQuery(); 95 | } 96 | } 97 | 98 | return new Uri(Uri::composeComponents( 99 | $base->getScheme(), 100 | $targetAuthority, 101 | $targetPath, 102 | $targetQuery, 103 | $rel->getFragment() 104 | )); 105 | } 106 | 107 | /** 108 | * Returns the target URI as a relative reference from the base URI. 109 | * 110 | * This method is the counterpart to resolve(): 111 | * 112 | * (string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target)) 113 | * 114 | * One use-case is to use the current request URI as base URI and then generate relative links in your documents 115 | * to reduce the document size or offer self-contained downloadable document archives. 116 | * 117 | * $base = new Uri('http://example.com/a/b/'); 118 | * echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'. 119 | * echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'. 120 | * echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'. 121 | * echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'. 122 | * 123 | * This method also accepts a target that is already relative and will try to relativize it further. Only a 124 | * relative-path reference will be returned as-is. 125 | * 126 | * echo UriResolver::relativize($base, new Uri('/a/b/c')); // prints 'c' as well 127 | */ 128 | public static function relativize(UriInterface $base, UriInterface $target): UriInterface 129 | { 130 | if ($target->getScheme() !== '' 131 | && ($base->getScheme() !== $target->getScheme() || $target->getAuthority() === '' && $base->getAuthority() !== '') 132 | ) { 133 | return $target; 134 | } 135 | 136 | if (Uri::isRelativePathReference($target)) { 137 | // As the target is already highly relative we return it as-is. It would be possible to resolve 138 | // the target with `$target = self::resolve($base, $target);` and then try make it more relative 139 | // by removing a duplicate query. But let's not do that automatically. 140 | return $target; 141 | } 142 | 143 | if ($target->getAuthority() !== '' && $base->getAuthority() !== $target->getAuthority()) { 144 | return $target->withScheme(''); 145 | } 146 | 147 | // We must remove the path before removing the authority because if the path starts with two slashes, the URI 148 | // would turn invalid. And we also cannot set a relative path before removing the authority, as that is also 149 | // invalid. 150 | $emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost(''); 151 | 152 | if ($base->getPath() !== $target->getPath()) { 153 | return $emptyPathUri->withPath(self::getRelativePath($base, $target)); 154 | } 155 | 156 | if ($base->getQuery() === $target->getQuery()) { 157 | // Only the target fragment is left. And it must be returned even if base and target fragment are the same. 158 | return $emptyPathUri->withQuery(''); 159 | } 160 | 161 | // If the base URI has a query but the target has none, we cannot return an empty path reference as it would 162 | // inherit the base query component when resolving. 163 | if ($target->getQuery() === '') { 164 | $segments = explode('/', $target->getPath()); 165 | /** @var string $lastSegment */ 166 | $lastSegment = end($segments); 167 | 168 | return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment); 169 | } 170 | 171 | return $emptyPathUri; 172 | } 173 | 174 | private static function getRelativePath(UriInterface $base, UriInterface $target): string 175 | { 176 | $sourceSegments = explode('/', $base->getPath()); 177 | $targetSegments = explode('/', $target->getPath()); 178 | array_pop($sourceSegments); 179 | $targetLastSegment = array_pop($targetSegments); 180 | foreach ($sourceSegments as $i => $segment) { 181 | if (isset($targetSegments[$i]) && $segment === $targetSegments[$i]) { 182 | unset($sourceSegments[$i], $targetSegments[$i]); 183 | } else { 184 | break; 185 | } 186 | } 187 | $targetSegments[] = $targetLastSegment; 188 | $relativePath = str_repeat('../', count($sourceSegments)).implode('/', $targetSegments); 189 | 190 | // A reference to am empty last segment or an empty first sub-segment must be prefixed with "./". 191 | // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used 192 | // as the first segment of a relative-path reference, as it would be mistaken for a scheme name. 193 | if ('' === $relativePath || false !== strpos(explode('/', $relativePath, 2)[0], ':')) { 194 | $relativePath = "./$relativePath"; 195 | } elseif ('/' === $relativePath[0]) { 196 | if ($base->getAuthority() != '' && $base->getPath() === '') { 197 | // In this case an extra slash is added by resolve() automatically. So we must not add one here. 198 | $relativePath = ".$relativePath"; 199 | } else { 200 | $relativePath = "./$relativePath"; 201 | } 202 | } 203 | 204 | return $relativePath; 205 | } 206 | 207 | private function __construct() 208 | { 209 | // cannot be instantiated 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Utils.php: -------------------------------------------------------------------------------- 1 | $v) { 28 | if (!in_array(strtolower((string) $k), $keys)) { 29 | $result[$k] = $v; 30 | } 31 | } 32 | 33 | return $result; 34 | } 35 | 36 | /** 37 | * Copy the contents of a stream into another stream until the given number 38 | * of bytes have been read. 39 | * 40 | * @param StreamInterface $source Stream to read from 41 | * @param StreamInterface $dest Stream to write to 42 | * @param int $maxLen Maximum number of bytes to read. Pass -1 43 | * to read the entire stream. 44 | * 45 | * @throws \RuntimeException on error. 46 | */ 47 | public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): void 48 | { 49 | $bufferSize = 8192; 50 | 51 | if ($maxLen === -1) { 52 | while (!$source->eof()) { 53 | if (!$dest->write($source->read($bufferSize))) { 54 | break; 55 | } 56 | } 57 | } else { 58 | $remaining = $maxLen; 59 | while ($remaining > 0 && !$source->eof()) { 60 | $buf = $source->read(min($bufferSize, $remaining)); 61 | $len = strlen($buf); 62 | if (!$len) { 63 | break; 64 | } 65 | $remaining -= $len; 66 | $dest->write($buf); 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Copy the contents of a stream into a string until the given number of 73 | * bytes have been read. 74 | * 75 | * @param StreamInterface $stream Stream to read 76 | * @param int $maxLen Maximum number of bytes to read. Pass -1 77 | * to read the entire stream. 78 | * 79 | * @throws \RuntimeException on error. 80 | */ 81 | public static function copyToString(StreamInterface $stream, int $maxLen = -1): string 82 | { 83 | $buffer = ''; 84 | 85 | if ($maxLen === -1) { 86 | while (!$stream->eof()) { 87 | $buf = $stream->read(1048576); 88 | if ($buf === '') { 89 | break; 90 | } 91 | $buffer .= $buf; 92 | } 93 | 94 | return $buffer; 95 | } 96 | 97 | $len = 0; 98 | while (!$stream->eof() && $len < $maxLen) { 99 | $buf = $stream->read($maxLen - $len); 100 | if ($buf === '') { 101 | break; 102 | } 103 | $buffer .= $buf; 104 | $len = strlen($buffer); 105 | } 106 | 107 | return $buffer; 108 | } 109 | 110 | /** 111 | * Calculate a hash of a stream. 112 | * 113 | * This method reads the entire stream to calculate a rolling hash, based 114 | * on PHP's `hash_init` functions. 115 | * 116 | * @param StreamInterface $stream Stream to calculate the hash for 117 | * @param string $algo Hash algorithm (e.g. md5, crc32, etc) 118 | * @param bool $rawOutput Whether or not to use raw output 119 | * 120 | * @throws \RuntimeException on error. 121 | */ 122 | public static function hash(StreamInterface $stream, string $algo, bool $rawOutput = false): string 123 | { 124 | $pos = $stream->tell(); 125 | 126 | if ($pos > 0) { 127 | $stream->rewind(); 128 | } 129 | 130 | $ctx = hash_init($algo); 131 | while (!$stream->eof()) { 132 | hash_update($ctx, $stream->read(1048576)); 133 | } 134 | 135 | $out = hash_final($ctx, $rawOutput); 136 | $stream->seek($pos); 137 | 138 | return $out; 139 | } 140 | 141 | /** 142 | * Clone and modify a request with the given changes. 143 | * 144 | * This method is useful for reducing the number of clones needed to mutate 145 | * a message. 146 | * 147 | * The changes can be one of: 148 | * - method: (string) Changes the HTTP method. 149 | * - set_headers: (array) Sets the given headers. 150 | * - remove_headers: (array) Remove the given headers. 151 | * - body: (mixed) Sets the given body. 152 | * - uri: (UriInterface) Set the URI. 153 | * - query: (string) Set the query string value of the URI. 154 | * - version: (string) Set the protocol version. 155 | * 156 | * @param RequestInterface $request Request to clone and modify. 157 | * @param array $changes Changes to apply. 158 | */ 159 | public static function modifyRequest(RequestInterface $request, array $changes): RequestInterface 160 | { 161 | if (!$changes) { 162 | return $request; 163 | } 164 | 165 | $headers = $request->getHeaders(); 166 | 167 | if (!isset($changes['uri'])) { 168 | $uri = $request->getUri(); 169 | } else { 170 | // Remove the host header if one is on the URI 171 | if ($host = $changes['uri']->getHost()) { 172 | $changes['set_headers']['Host'] = $host; 173 | 174 | if ($port = $changes['uri']->getPort()) { 175 | $standardPorts = ['http' => 80, 'https' => 443]; 176 | $scheme = $changes['uri']->getScheme(); 177 | if (isset($standardPorts[$scheme]) && $port != $standardPorts[$scheme]) { 178 | $changes['set_headers']['Host'] .= ':'.$port; 179 | } 180 | } 181 | } 182 | $uri = $changes['uri']; 183 | } 184 | 185 | if (!empty($changes['remove_headers'])) { 186 | $headers = self::caselessRemove($changes['remove_headers'], $headers); 187 | } 188 | 189 | if (!empty($changes['set_headers'])) { 190 | $headers = self::caselessRemove(array_keys($changes['set_headers']), $headers); 191 | $headers = $changes['set_headers'] + $headers; 192 | } 193 | 194 | if (isset($changes['query'])) { 195 | $uri = $uri->withQuery($changes['query']); 196 | } 197 | 198 | if ($request instanceof ServerRequestInterface) { 199 | $new = (new ServerRequest( 200 | $changes['method'] ?? $request->getMethod(), 201 | $uri, 202 | $headers, 203 | $changes['body'] ?? $request->getBody(), 204 | $changes['version'] ?? $request->getProtocolVersion(), 205 | $request->getServerParams() 206 | )) 207 | ->withParsedBody($request->getParsedBody()) 208 | ->withQueryParams($request->getQueryParams()) 209 | ->withCookieParams($request->getCookieParams()) 210 | ->withUploadedFiles($request->getUploadedFiles()); 211 | 212 | foreach ($request->getAttributes() as $key => $value) { 213 | $new = $new->withAttribute($key, $value); 214 | } 215 | 216 | return $new; 217 | } 218 | 219 | return new Request( 220 | $changes['method'] ?? $request->getMethod(), 221 | $uri, 222 | $headers, 223 | $changes['body'] ?? $request->getBody(), 224 | $changes['version'] ?? $request->getProtocolVersion() 225 | ); 226 | } 227 | 228 | /** 229 | * Read a line from the stream up to the maximum allowed buffer length. 230 | * 231 | * @param StreamInterface $stream Stream to read from 232 | * @param int|null $maxLength Maximum buffer length 233 | */ 234 | public static function readLine(StreamInterface $stream, ?int $maxLength = null): string 235 | { 236 | $buffer = ''; 237 | $size = 0; 238 | 239 | while (!$stream->eof()) { 240 | if ('' === ($byte = $stream->read(1))) { 241 | return $buffer; 242 | } 243 | $buffer .= $byte; 244 | // Break when a new line is found or the max length - 1 is reached 245 | if ($byte === "\n" || ++$size === $maxLength - 1) { 246 | break; 247 | } 248 | } 249 | 250 | return $buffer; 251 | } 252 | 253 | /** 254 | * Redact the password in the user info part of a URI. 255 | */ 256 | public static function redactUserInfo(UriInterface $uri): UriInterface 257 | { 258 | $userInfo = $uri->getUserInfo(); 259 | 260 | if (false !== ($pos = \strpos($userInfo, ':'))) { 261 | return $uri->withUserInfo(\substr($userInfo, 0, $pos), '***'); 262 | } 263 | 264 | return $uri; 265 | } 266 | 267 | /** 268 | * Create a new stream based on the input type. 269 | * 270 | * Options is an associative array that can contain the following keys: 271 | * - metadata: Array of custom metadata. 272 | * - size: Size of the stream. 273 | * 274 | * This method accepts the following `$resource` types: 275 | * - `Psr\Http\Message\StreamInterface`: Returns the value as-is. 276 | * - `string`: Creates a stream object that uses the given string as the contents. 277 | * - `resource`: Creates a stream object that wraps the given PHP stream resource. 278 | * - `Iterator`: If the provided value implements `Iterator`, then a read-only 279 | * stream object will be created that wraps the given iterable. Each time the 280 | * stream is read from, data from the iterator will fill a buffer and will be 281 | * continuously called until the buffer is equal to the requested read size. 282 | * Subsequent read calls will first read from the buffer and then call `next` 283 | * on the underlying iterator until it is exhausted. 284 | * - `object` with `__toString()`: If the object has the `__toString()` method, 285 | * the object will be cast to a string and then a stream will be returned that 286 | * uses the string value. 287 | * - `NULL`: When `null` is passed, an empty stream object is returned. 288 | * - `callable` When a callable is passed, a read-only stream object will be 289 | * created that invokes the given callable. The callable is invoked with the 290 | * number of suggested bytes to read. The callable can return any number of 291 | * bytes, but MUST return `false` when there is no more data to return. The 292 | * stream object that wraps the callable will invoke the callable until the 293 | * number of requested bytes are available. Any additional bytes will be 294 | * buffered and used in subsequent reads. 295 | * 296 | * @param resource|string|int|float|bool|StreamInterface|callable|\Iterator|null $resource Entity body data 297 | * @param array{size?: int, metadata?: array} $options Additional options 298 | * 299 | * @throws \InvalidArgumentException if the $resource arg is not valid. 300 | */ 301 | public static function streamFor($resource = '', array $options = []): StreamInterface 302 | { 303 | if (is_scalar($resource)) { 304 | $stream = self::tryFopen('php://temp', 'r+'); 305 | if ($resource !== '') { 306 | fwrite($stream, (string) $resource); 307 | fseek($stream, 0); 308 | } 309 | 310 | return new Stream($stream, $options); 311 | } 312 | 313 | switch (gettype($resource)) { 314 | case 'resource': 315 | /* 316 | * The 'php://input' is a special stream with quirks and inconsistencies. 317 | * We avoid using that stream by reading it into php://temp 318 | */ 319 | 320 | /** @var resource $resource */ 321 | if ((\stream_get_meta_data($resource)['uri'] ?? '') === 'php://input') { 322 | $stream = self::tryFopen('php://temp', 'w+'); 323 | stream_copy_to_stream($resource, $stream); 324 | fseek($stream, 0); 325 | $resource = $stream; 326 | } 327 | 328 | return new Stream($resource, $options); 329 | case 'object': 330 | /** @var object $resource */ 331 | if ($resource instanceof StreamInterface) { 332 | return $resource; 333 | } elseif ($resource instanceof \Iterator) { 334 | return new PumpStream(function () use ($resource) { 335 | if (!$resource->valid()) { 336 | return false; 337 | } 338 | $result = $resource->current(); 339 | $resource->next(); 340 | 341 | return $result; 342 | }, $options); 343 | } elseif (method_exists($resource, '__toString')) { 344 | return self::streamFor((string) $resource, $options); 345 | } 346 | break; 347 | case 'NULL': 348 | return new Stream(self::tryFopen('php://temp', 'r+'), $options); 349 | } 350 | 351 | if (is_callable($resource)) { 352 | return new PumpStream($resource, $options); 353 | } 354 | 355 | throw new \InvalidArgumentException('Invalid resource type: '.gettype($resource)); 356 | } 357 | 358 | /** 359 | * Safely opens a PHP stream resource using a filename. 360 | * 361 | * When fopen fails, PHP normally raises a warning. This function adds an 362 | * error handler that checks for errors and throws an exception instead. 363 | * 364 | * @param string $filename File to open 365 | * @param string $mode Mode used to open the file 366 | * 367 | * @return resource 368 | * 369 | * @throws \RuntimeException if the file cannot be opened 370 | */ 371 | public static function tryFopen(string $filename, string $mode) 372 | { 373 | $ex = null; 374 | set_error_handler(static function (int $errno, string $errstr) use ($filename, $mode, &$ex): bool { 375 | $ex = new \RuntimeException(sprintf( 376 | 'Unable to open "%s" using mode "%s": %s', 377 | $filename, 378 | $mode, 379 | $errstr 380 | )); 381 | 382 | return true; 383 | }); 384 | 385 | try { 386 | /** @var resource $handle */ 387 | $handle = fopen($filename, $mode); 388 | } catch (\Throwable $e) { 389 | $ex = new \RuntimeException(sprintf( 390 | 'Unable to open "%s" using mode "%s": %s', 391 | $filename, 392 | $mode, 393 | $e->getMessage() 394 | ), 0, $e); 395 | } 396 | 397 | restore_error_handler(); 398 | 399 | if ($ex) { 400 | /** @var $ex \RuntimeException */ 401 | throw $ex; 402 | } 403 | 404 | return $handle; 405 | } 406 | 407 | /** 408 | * Safely gets the contents of a given stream. 409 | * 410 | * When stream_get_contents fails, PHP normally raises a warning. This 411 | * function adds an error handler that checks for errors and throws an 412 | * exception instead. 413 | * 414 | * @param resource $stream 415 | * 416 | * @throws \RuntimeException if the stream cannot be read 417 | */ 418 | public static function tryGetContents($stream): string 419 | { 420 | $ex = null; 421 | set_error_handler(static function (int $errno, string $errstr) use (&$ex): bool { 422 | $ex = new \RuntimeException(sprintf( 423 | 'Unable to read stream contents: %s', 424 | $errstr 425 | )); 426 | 427 | return true; 428 | }); 429 | 430 | try { 431 | /** @var string|false $contents */ 432 | $contents = stream_get_contents($stream); 433 | 434 | if ($contents === false) { 435 | $ex = new \RuntimeException('Unable to read stream contents'); 436 | } 437 | } catch (\Throwable $e) { 438 | $ex = new \RuntimeException(sprintf( 439 | 'Unable to read stream contents: %s', 440 | $e->getMessage() 441 | ), 0, $e); 442 | } 443 | 444 | restore_error_handler(); 445 | 446 | if ($ex) { 447 | /** @var $ex \RuntimeException */ 448 | throw $ex; 449 | } 450 | 451 | return $contents; 452 | } 453 | 454 | /** 455 | * Returns a UriInterface for the given value. 456 | * 457 | * This function accepts a string or UriInterface and returns a 458 | * UriInterface for the given value. If the value is already a 459 | * UriInterface, it is returned as-is. 460 | * 461 | * @param string|UriInterface $uri 462 | * 463 | * @throws \InvalidArgumentException 464 | */ 465 | public static function uriFor($uri): UriInterface 466 | { 467 | if ($uri instanceof UriInterface) { 468 | return $uri; 469 | } 470 | 471 | if (is_string($uri)) { 472 | return new Uri($uri); 473 | } 474 | 475 | throw new \InvalidArgumentException('URI must be a string or UriInterface'); 476 | } 477 | } 478 | --------------------------------------------------------------------------------