From f9da86cfc824c1598eca0be00c35db6617f82213 Mon Sep 17 00:00:00 2001 From: smiley Date: Sat, 9 Mar 2024 22:22:34 +0100 Subject: [PATCH] :sparkles: --- .github/workflows/ci.yml | 27 +- .phan/config.php | 6 +- README.md | 70 ++-- composer.json | 37 +- phpunit.xml.dist | 13 +- src/DummyStream.php | 175 ++++++++ src/HTTPFactory.php | 164 ++++++++ src/Message.php | 176 ++++++++ src/MultipartStreamBuilder.php | 272 ++++++++++++ src/Request.php | 148 +++++++ src/Response.php | 153 +++++++ src/ServerRequest.php | 156 +++++++ src/Stream.php | 296 +++++++++++++ src/UploadedFile.php | 188 +++++++++ src/Uri.php | 383 +++++++++++++++++ tests/DummyStreamTest.php | 93 +++++ tests/FactoryTrait.php | 74 ++++ tests/FactoryUtilsTest.php | 74 ++++ tests/MessageTest.php | 167 ++++++++ tests/MultipartStreamBuilderTest.php | 372 +++++++++++++++++ tests/RequestTest.php | 131 ++++++ tests/ResponseTest.php | 80 ++++ tests/ServerRequestTest.php | 123 ++++++ tests/StreamTest.php | 188 +++++++++ tests/UploadedFileTest.php | 219 ++++++++++ tests/UriTest.php | 600 +++++++++++++++++++++++++++ 26 files changed, 4318 insertions(+), 67 deletions(-) create mode 100644 src/DummyStream.php create mode 100644 src/HTTPFactory.php create mode 100644 src/Message.php create mode 100644 src/MultipartStreamBuilder.php create mode 100644 src/Request.php create mode 100644 src/Response.php create mode 100644 src/ServerRequest.php create mode 100644 src/Stream.php create mode 100644 src/UploadedFile.php create mode 100644 src/Uri.php create mode 100644 tests/DummyStreamTest.php create mode 100644 tests/FactoryTrait.php create mode 100644 tests/FactoryUtilsTest.php create mode 100644 tests/MessageTest.php create mode 100644 tests/MultipartStreamBuilderTest.php create mode 100644 tests/RequestTest.php create mode 100644 tests/ResponseTest.php create mode 100644 tests/ServerRequestTest.php create mode 100644 tests/StreamTest.php create mode 100644 tests/UploadedFileTest.php create mode 100644 tests/UriTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 365724c..cce81ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ on: name: "Continuous Integration" env: - PHP_EXTENSIONS: "" # caution: setting 'none' resets/disables shared extensions + PHP_EXTENSIONS: fileinfo, intl, json, mbstring, simplexml, sodium, zlib PHP_INI_VALUES: memory_limit=-1, error_reporting=-1, display_errors=On jobs: @@ -137,28 +137,3 @@ jobs: branch: gh-pages folder: .build/phpdocs clean: true - - - build-manual: - name: "Build and publish user manual" - if: github.ref_name == 'main' - runs-on: ubuntu-latest - - steps: - - name: "Checkout sources" - uses: actions/checkout@v4 - - - name: "Install Sphinx" - run: pip install sphinx myst-parser sphinx-rtd-theme - - - name: "Build manual" - run: | - cd docs - make html - - - name: "Publish user manual to branch readthedocs" - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: readthedocs - folder: .build/sphinx/html - clean: true diff --git a/.phan/config.php b/.phan/config.php index b62fe11..75990ee 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -27,8 +27,8 @@ // Thus, both first-party and third-party code being used by // your application should be included in this list. 'directory_list' => [ - '.phan/stubs', - 'examples', +# '.phan/stubs', +# 'examples', 'src', 'tests', 'vendor', @@ -57,5 +57,7 @@ ], 'suppress_issue_types' => [ 'PhanAccessMethodInternal', + 'PhanNoopCast', + 'PhanNoopNew', ], ]; diff --git a/README.md b/README.md index cba86fa..eaad8e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# chillerlan/php-library-template +# chillerlan/psr-7 -A template/boilerplate for PHP libraries. +A [PSR-7](https://www.php-fig.org/psr/psr-7/)/[PSR-17](https://www.php-fig.org/psr/psr-17/) HTTP message and factory implementation. [![PHP Version Support][php-badge]][php] [![Packagist version][packagist-badge]][packagist] @@ -10,46 +10,62 @@ A template/boilerplate for PHP libraries. [![Codacy][codacy-badge]][codacy] [![Packagist downloads][downloads-badge]][downloads] -[php-badge]: https://img.shields.io/packagist/php-v/chillerlan/php-library-template?logo=php&color=8892BF&logoColor=fff +[php-badge]: https://img.shields.io/packagist/php-v/chillerlan/psr-7?logo=php&color=8892BF&logoColor=fff [php]: https://www.php.net/supported-versions.php -[packagist-badge]: https://img.shields.io/packagist/v/chillerlan/php-library-template.svg?logo=packagist&logoColor=fff -[packagist]: https://packagist.org/packages/chillerlan/php-library-template -[license-badge]: https://img.shields.io/github/license/chillerlan/php-library-template.svg -[license]: https://github.com/chillerlan/php-library-template/blob/main/LICENSE -[gh-action-badge]: https://img.shields.io/github/actions/workflow/status/chillerlan/php-library-template/ci.yml?branch=main&logo=github&logoColor=fff -[gh-action]: https://github.com/chillerlan/php-library-template/actions/workflows/ci.yml?query=branch%3Amain -[coverage-badge]: https://img.shields.io/codecov/c/github/chillerlan/php-library-template.svg?logo=codecov&logoColor=fff -[coverage]: https://codecov.io/github/chillerlan/php-library-template +[packagist-badge]: https://img.shields.io/packagist/v/chillerlan/psr-7.svg?logo=packagist&logoColor=fff +[packagist]: https://packagist.org/packages/chillerlan/psr-7 +[license-badge]: https://img.shields.io/github/license/chillerlan/psr-7.svg +[license]: https://github.com/chillerlan/psr-7/blob/main/LICENSE +[gh-action-badge]: https://img.shields.io/github/actions/workflow/status/chillerlan/psr-7/ci.yml?branch=main&logo=github&logoColor=fff +[gh-action]: https://github.com/chillerlan/psr-7/actions/workflows/ci.yml?query=branch%3Amain +[coverage-badge]: https://img.shields.io/codecov/c/github/chillerlan/psr-7.svg?logo=codecov&logoColor=fff +[coverage]: https://codecov.io/github/chillerlan/psr-7 [codacy-badge]: https://img.shields.io/codacy/grade/de971588f9a44f1a99e7bbd2a0737951?logo=codacy&logoColor=fff -[codacy]: https://app.codacy.com/gh/chillerlan/php-library-template/dashboard -[downloads-badge]: https://img.shields.io/packagist/dt/chillerlan/php-library-template.svg?logo=packagist&logoColor=fff -[downloads]: https://packagist.org/packages/chillerlan/php-library-template/stats +[codacy]: https://app.codacy.com/gh/chillerlan/psr-7/dashboard +[downloads-badge]: https://img.shields.io/packagist/dt/chillerlan/psr-7.svg?logo=packagist&logoColor=fff +[downloads]: https://packagist.org/packages/chillerlan/psr-7/stats ## Overview ### Features -- [GitHub Actions](https://github.com/chillerlan/php-library-template/actions) runner -- [Composer](https://getcomposer.org) dependency management -- [PHPUnit](https://phpunit.de) unit tests -- [PHAN](https://github.com/phan/phan) static analysis -- [PHPCS](https://github.com/PHPCSStandards/PHP_CodeSniffer) coding standard analyzer -- [PHPMD](https://phpmd.org) mess detector -- [Codecov](https://codecov.io) code coverage analysis -- [Codacy](https://www.codacy.com) code quality analysis -- [phpDocumentor](https://www.phpdoc.org) auto generated API docs -- [ReadTheDocs](https://readthedocs.org) documentation builder - +- [PSR-7](https://www.php-fig.org/psr/psr-7/) HTTP message implementation +- [PSR-17](https://www.php-fig.org/psr/psr-17/) HTTP factory implementation +- `MultipartStreamBuilder` based on PSR-7 `Message` objects ([RFC-2046, section 5.1](https://datatracker.ietf.org/doc/html/rfc2046#section-5.1)) ### Requirements - PHP 8.1+ + - [`ext-mbstring`](https://www.php.net/manual/book.mbstring.php) ## Documentation -- The user manual is at https://php-library-template.readthedocs.io/ ([sources](https://github.com/chillerlan/php-library-template/tree/main/docs)) -- An API documentation created with [phpDocumentor](https://www.phpdoc.org/) can be found at https://chillerlan.github.io/php-library-template/ +The documentation of the PSR-7 interfaces can be found over at https://www.php-fig.org/psr/psr-7/. + +**NOTE: This library has abandoned the paranoid "value object" "immuatbility" that is dictated by PSR-7 for it is horseshit. +The pseudo-immutability gets in the way more often (always) than it is useful (never) and creates endless overhead. +If you want your objects to be immutable for whatever reason, just fucking clone them and don't force countless libraries +to do that for you instead. If you don't like it, just use Guzzle instead (spoiler: you won't notice the difference).** + +Further, this library still only implements [`psr/http-message`](https://packagist.org/packages/psr/http-message) v1.1, +as the v2.0 release (06/2023) has return types added [that conflict](https://github.com/php-fig/http-message/pull/107) +with the [`static` return type](https://wiki.php.net/rfc/static_return_type) that was introduced in PHP 8 (11/2020). + + +### Auto generated API documentation + +The API documentation can be auto generated with [phpDocumentor](https://www.phpdoc.org/). +There is an [online version available](https://chillerlan.github.io/psr-7/) via the [gh-pages branch](https://github.com/chillerlan/psr-7/tree/gh-pages) that is [automatically deployed](https://github.com/chillerlan/psr-7/deployments) on each push to main. + +Locally created docs will appear in `.build/phpdocs/`. If you'd like to create local docs, please follow these steps: + +- [download phpDocumentor](https://github.com/phpDocumentor/phpDocumentor/releases) v3+ as .phar archive +- run it in the repository root directory: + - on Windows `c:\path\to\php.exe c:\path\to\phpDocumentor.phar --config=phpdoc.xml` + - on Linux just `php /path/to/phpDocumentor.phar --config=phpdoc.xml` +- open [index.html](./.build/phpdocs/index.html) in a browser +- profit! ## Disclaimer diff --git a/composer.json b/composer.json index d3c8037..0a8e066 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,11 @@ { - "name": "chillerlan/php-library-template", - "description": "A PHP Library template/boilerplate.", + "name": "chillerlan/psr-7", + "description": "A PSR-7 HTTP message and PSR-17 HTTP factory implementation.", "license": "MIT", "type": "library", - "keywords": ["boilerplate", "library-template"], + "keywords": [ + "http", "message", "factory", "psr-7", "psr-17", "request", "response", "message", "stream", "uri", "url" + ], "authors": [ { "name": "smiley", @@ -12,36 +14,49 @@ }, { "name": "Contributors", - "homepage":"https://github.com/chillerlan/php-library-template/graphs/contributors" + "homepage":"https://github.com/chillerlan/psr-7/graphs/contributors" } ], - "homepage": "https://github.com/chillerlan/php-library-template", + "homepage": "https://github.com/chillerlan/psr-7", "support": { - "docs": "https://php-library-template.readthedocs.io", - "issues": "https://github.com/chillerlan/php-library-template/issues", - "source": "https://github.com/chillerlan/php-library-template" + "docs": "https://chillerlan.github.io/psr-7/", + "issues": "https://github.com/chillerlan/psr-7/issues", + "source": "https://github.com/chillerlan/psr-7" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" }, "minimum-stability": "stable", "prefer-stable": true, "require": { - "php": "^8.1" + "php": "^8.1", + "ext-mbstring": "*", + "chillerlan/php-http-message-utils": "^2.1.1", + "fig/http-message-util": "^1.1.5", + "psr/http-message": "^1.1", + "psr/http-factory": "^1.0" }, "require-dev": { + "ext-simplexml": "*", + "http-interop/http-factory-tests": "^2.1", "phan/phan": "^5.4", "phpunit/phpunit": "^10.5", "phpmd/phpmd": "^2.15", "squizlabs/php_codesniffer": "^3.9" }, "suggest": { + "chillerlan/php-httpinterface": "A PSR-18 HTTP client implementation", + "chillerlan/php-oauth": "A PSR-7 OAuth client/handler that also acts as PSR-18 HTTP client" }, "autoload": { "psr-4": { - "chillerlan\\LibraryTemplate\\": "src/" + "chillerlan\\HTTP\\Psr7\\": "src/" } }, "autoload-dev": { "psr-4": { - "chillerlan\\LibraryTemplateTest\\": "tests/" + "chillerlan\\HTTPTest\\Psr7\\": "tests/" } }, "scripts": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 647daf9..3956d1e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,9 +6,12 @@ colors="true" > - + ./tests/ + + ./vendor/http-interop/http-factory-tests/test + @@ -21,4 +24,12 @@ + + + + + + + + diff --git a/src/DummyStream.php b/src/DummyStream.php new file mode 100644 index 0000000..afb65a8 --- /dev/null +++ b/src/DummyStream.php @@ -0,0 +1,175 @@ + + * @copyright 2023 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use Psr\Http\Message\StreamInterface; +use Closure; +use function array_diff, array_keys, in_array; +use const SEEK_SET; + +/** + * A stream handler that allows to override select methods of the given StreamInterface + */ +class DummyStream implements StreamInterface{ + + protected const STREAMINTERFACE_METHODS = [ + '__toString', + 'close', + 'detach', + 'rewind', + 'getSize', + 'tell', + 'eof', + 'isSeekable', + 'seek', + 'isWritable', + 'write', + 'isReadable', + 'read', + 'getContents', + 'getMetadata', + ]; + + protected StreamInterface $stream; + protected array $override = []; + + /** + * DummyStream constructor + */ + public function __construct(StreamInterface|null $stream = null, array|null $methods = null){ + + $this + ->dummySetStream($stream ?? HTTPFactory::createStreamFromString()) + ->dummyOverrideAll(($methods ?? [])) + ; + } + + /** + * Sets a StreamInterface to override + */ + public function dummySetStream(StreamInterface $stream):static{ + $this->stream = $stream; + + return $this; + } + + /** + * Sets the override methods + * + * @param \Closure[] $methods + */ + public function dummyOverrideAll(array $methods):static{ + + foreach($methods as $name => $fn){ + $this->dummyOverrideMethod($name, $fn); + } + + foreach(array_diff($this::STREAMINTERFACE_METHODS, array_keys($this->override)) as $name){ + $this->override[$name] = $this->stream->{$name}(...); + } + + return $this; + } + + /** + * Sets a single override method + */ + public function dummyOverrideMethod(string $name, Closure $fn):static{ + + if(in_array($name, $this::STREAMINTERFACE_METHODS)){ + $this->override[$name] = $fn; + } + + return $this; + } + + public function __destruct(){ + $this->override['close'](); + } + + /** @inheritDoc */ + public function __toString():string{ + return $this->override['__toString'](); + } + + /** @inheritDoc */ + public function close():void{ + $this->override['close'](); + } + + /** @inheritDoc */ + public function detach(){ + return $this->override['detach'](); + } + + /** @inheritDoc */ + public function getSize():int|null{ + return $this->override['getSize'](); + } + + /** @inheritDoc */ + public function tell():int{ + return $this->override['tell'](); + } + + /** @inheritDoc */ + public function eof():bool{ + return $this->override['eof'](); + } + + /** @inheritDoc */ + public function isSeekable():bool{ + return $this->override['isSeekable'](); + } + + /** @inheritDoc */ + public function seek(int $offset, int $whence = SEEK_SET):void{ + $this->override['seek']($offset, $whence); + } + + /** @inheritDoc */ + public function rewind():void{ + $this->override['rewind'](); + } + + /** @inheritDoc */ + public function isWritable():bool{ + return $this->override['isWritable'](); + } + + /** @inheritDoc */ + public function write(string $string):int{ + return $this->override['write']($string); + } + + /** @inheritDoc */ + public function isReadable():bool{ + return $this->override['isReadable'](); + } + + /** @inheritDoc */ + public function read(int $length):string{ + return $this->override['read']($length); + } + + /** @inheritDoc */ + public function getContents():string{ + return $this->override['getContents'](); + } + + /** @inheritDoc */ + public function getMetadata(string|null $key = null):mixed{ + return $this->override['getMetadata']($key); + } + +} diff --git a/src/HTTPFactory.php b/src/HTTPFactory.php new file mode 100644 index 0000000..8b1457e --- /dev/null +++ b/src/HTTPFactory.php @@ -0,0 +1,164 @@ + + * @copyright 2024 smiley + * @license MIT + */ + +namespace chillerlan\HTTP\Psr7; + +use chillerlan\HTTP\Utils\StreamUtil; +use Fig\Http\Message\{RequestMethodInterface, StatusCodeInterface}; +use Psr\Http\Message\{ + RequestFactoryInterface, RequestInterface, ResponseFactoryInterface, ResponseInterface, ServerRequestFactoryInterface, + ServerRequestInterface, StreamFactoryInterface, StreamInterface, UploadedFileFactoryInterface, UploadedFileInterface, + UriFactoryInterface, UriInterface +}; +use InvalidArgumentException, RuntimeException, Stringable; +use function fseek, is_file, is_readable, is_scalar, stream_copy_to_stream, stream_get_meta_data; +use const UPLOAD_ERR_OK; + +/** + * Implements the PSR-17 HTTP factories + */ +class HTTPFactory implements + RequestFactoryInterface, + ResponseFactoryInterface, + RequestMethodInterface, + ServerRequestFactoryInterface, + StatusCodeInterface, + StreamFactoryInterface, + UploadedFileFactoryInterface, + UriFactoryInterface { + + /** + * @inheritDoc + */ + public function createRequest(string $method, $uri):RequestInterface{ + return new Request($method, $uri); + } + + /** + * @inheritDoc + */ + public function createResponse(int $code = 200, string $reasonPhrase = ''):ResponseInterface{ + return new Response($code, $reasonPhrase); + } + + /** + * @inheritDoc + */ + public function createStream(string $content = ''):StreamInterface{ + return static::createStreamFromString(content: $content, rewind: false); + } + + /** + * @inheritDoc + */ + public function createStreamFromFile(string $filename, string $mode = 'r'):StreamInterface{ + + if(empty($filename) || !is_file($filename) || !is_readable($filename)){ + throw new RuntimeException('invalid file'); + } + + return new Stream(StreamUtil::tryFopen($filename, $mode)); + } + + /** + * @inheritDoc + */ + public function createStreamFromResource($resource):StreamInterface{ + return new Stream($resource); + } + + /** + * @inheritDoc + */ + public function createUri(string $uri = ''):UriInterface{ + return new Uri($uri); + } + + /** + * @inheritDoc + */ + public function createServerRequest(string $method, $uri, array $serverParams = []):ServerRequestInterface{ + return new ServerRequest($method, $uri, $serverParams); + } + + /** + * @inheritDoc + */ + public function createUploadedFile( + StreamInterface $stream, + int|null $size = null, + int $error = UPLOAD_ERR_OK, + string|null $clientFilename = null, + string|null $clientMediaType = null, + ):UploadedFileInterface{ + return new UploadedFile($stream, ($size ?? (int)$stream->getSize()), $error, $clientFilename, $clientMediaType); + } + + /** + * Create a new writable stream from a string. + */ + public static function createStreamFromString(string $content = '', string $mode = 'r+', bool $rewind = true):StreamInterface{ + + if(!StreamUtil::modeAllowsWrite($mode)){ + throw new InvalidArgumentException('invalid mode for writing'); + } + + $stream = new Stream(StreamUtil::tryFopen('php://temp', $mode)); + + if($content !== ''){ + $stream->write($content); + } + + if($rewind === true){ + $stream->rewind(); + } + + return $stream; + } + + /** + * Creates a StreamInterface from the given source + */ + public static function createStreamFromSource(mixed $source = null):StreamInterface{ + $source ??= ''; + + if($source instanceof StreamInterface){ + return $source; + } + + if($source instanceof Stringable || is_scalar($source)){ + return static::createStreamFromString((string)$source); + } + + $type = gettype($source); + + if($type === 'resource'){ + // avoid using php://input and copy over the contents to a new stream + if((stream_get_meta_data($source)['uri'] ?? '') === 'php://input'){ + $stream = StreamUtil::tryFopen('php://temp', 'r+'); + + stream_copy_to_stream($source, $stream); + fseek($stream, 0); + + return new Stream($stream); + } + + return new Stream($source); + } + + if($type === 'NULL'){ + return static::createStreamFromString(); + } + + throw new InvalidArgumentException('Invalid resource type: '.$type); + } + + +} diff --git a/src/Message.php b/src/Message.php new file mode 100644 index 0000000..e0cfcda --- /dev/null +++ b/src/Message.php @@ -0,0 +1,176 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use chillerlan\HTTP\Utils\HeaderUtil; +use Psr\Http\Message\{MessageInterface, StreamInterface}; +use function array_column, array_combine, array_merge, implode, is_array, strtolower, str_replace, trim; + +/** + * Implements a HTTP message + * + * @see https://datatracker.ietf.org/doc/html/rfc7230 + * @see https://datatracker.ietf.org/doc/html/rfc7231 + * @see https://datatracker.ietf.org/doc/html/rfc9110 + * @see https://datatracker.ietf.org/doc/html/rfc9112 + */ +class Message implements MessageInterface{ + + protected StreamInterface $body; + protected array $headers = []; + protected string $version = '1.1'; + + /** + * Message constructor. + */ + public function __construct(StreamInterface|string|null $body = null){ + $this->body = HTTPFactory::createStreamFromSource($body); + } + + /** + * @inheritDoc + */ + public function getProtocolVersion():string{ + return $this->version; + } + + /** + * @inheritDoc + */ + public function withProtocolVersion(string $version):static{ + $this->version = $version; + + return $this; + } + + /** + * @inheritDoc + */ + public function getHeaders():array{ + return array_combine(array_column($this->headers, 'name'), array_column($this->headers, 'value')); + } + + /** + * @inheritDoc + */ + public function hasHeader(string $name):bool{ + return isset($this->headers[strtolower($this->checkName($name))]); + } + + /** + * @inheritDoc + */ + public function getHeader(string $name):array{ + $name = $this->checkName($name); + + if(!$this->hasHeader($name)){ + return []; + } + + return $this->headers[strtolower($name)]['value']; + } + + /** + * @inheritDoc + */ + public function getHeaderLine(string $name):string{ + return implode(', ', $this->getHeader($name)); + } + + /** + * @inheritDoc + */ + public function withHeader(string $name, mixed $value):static{ + $name = $this->checkName($name); + + $this->headers[strtolower($name)] = ['name' => $name, 'value' => $this->checkValue($value)]; + + return $this; + } + + /** + * @inheritDoc + */ + public function withAddedHeader(string $name, mixed $value):static{ + $name = $this->checkName($name); + $lcName = strtolower($name); + + /** @phan-suppress-next-line PhanTypeMismatchArgumentInternal */ + $this->headers[$lcName] = [ + 'name' => ($this->headers[$lcName]['name'] ?? $name), + 'value' => array_merge(($this->headers[$lcName]['value'] ?? []), $this->checkValue($value)), + ]; + + return $this; + } + + /** + * @inheritDoc + */ + public function withoutHeader(string $name):static{ + $name = $this->checkName($name); + $lcName = strtolower($name); + + if(!isset($this->headers[$lcName])){ + return $this; + } + + unset($this->headers[$lcName]); + + return $this; + } + + /** + * @inheritDoc + */ + public function getBody():StreamInterface{ + return $this->body; + } + + /** + * @inheritDoc + */ + public function withBody(StreamInterface $body):static{ + $this->body = $body; + + return $this; + } + + /** + * checks/cleans a header name + * + * @see https://github.com/advisories/GHSA-wxmh-65f7-jcvw + */ + protected function checkName(string $name):string{ + return trim(str_replace(["\r", "\n", ' '], '', $name)); + } + + /** + * @see https://github.com/advisories/GHSA-wxmh-65f7-jcvw + * + * @return string[] + */ + protected function checkValue(mixed $value):array{ + + if(!is_array($value)){ + $value = [$value]; + } + + /** + * @noinspection PhpIncompatibleReturnTypeInspection + * @phan-suppress-next-next-line PhanTypeMismatchReturn + */ + return HeaderUtil::trimValues($value); + } + +} diff --git a/src/MultipartStreamBuilder.php b/src/MultipartStreamBuilder.php new file mode 100644 index 0000000..0caa32a --- /dev/null +++ b/src/MultipartStreamBuilder.php @@ -0,0 +1,272 @@ + + * @copyright 2023 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use chillerlan\HTTP\Utils\{HeaderUtil, MessageUtil, StreamUtil}; +use Psr\Http\Message\{MessageInterface, StreamFactoryInterface, StreamInterface}; +use InvalidArgumentException; +use function basename, count, implode, ksort, preg_match, random_bytes, sha1, sprintf, str_starts_with, trim; + +/** + * Use PSR-7 MessageInterface to build multipart messages + * + * @link https://datatracker.ietf.org/doc/html/rfc2046#section-5.1 + */ +class MultipartStreamBuilder{ + + /** @var \Psr\Http\Message\MessageInterface[] */ + protected array $messages; + protected string $boundary; + protected StreamInterface $multipartStream; + + /** + * MultipartStreamBuilder constructor + */ + public function __construct( + protected StreamFactoryInterface $streamFactory, + ){ + $this->reset(); + } + + /** + * Returns the stream content (make sure to save the boundary before!) + */ + public function __toString():string{ + return $this->build()->getContents(); + } + + /** + * Clears the MessageInterface array + */ + public function reset():static{ + $this->messages = []; + $this->boundary = $this->getRandomBoundary(); + + return $this; + } + + /** + * Sets a boundary string + * + * permitted characters: DIGIT ALPHA '()+_,-./:=? + * + * @see https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.1 + */ + public function setBoundary(string $boundary):static{ + $boundary = trim($boundary); + + if($boundary === ''){ + throw new InvalidArgumentException('The given boundary is empty'); + } + + if(!preg_match('#^[a-z\d\'()+_,-./:=?]+$#i', $boundary)){ + throw new InvalidArgumentException('The given boundary contains illegal characters'); + } + + $this->boundary = $boundary; + + return $this; + } + + /** + * Returns the current boundary string + */ + public function getBoundary():string{ + return $this->boundary; + } + + /** + * Generates a random boundary string + */ + protected function getRandomBoundary():string{ + return sha1(random_bytes(8192)); + } + + /** + * Adds a message with the given content + */ + public function addString( + string $content, + string|null $fieldname = null, + string|null $filename = null, + iterable|null $headers = null, + bool|null $setContentLength = null, + ):static{ + return $this->addStream($this->streamFactory->createStream($content), $fieldname, $filename, $headers, $setContentLength); + } + + /** + * Adds a StreamInterface + */ + public function addStream( + StreamInterface $stream, + string|null $fieldname = null, + string|null $filename = null, + iterable|null $headers = null, + bool|null $setContentLength = null, + ):static{ + $message = new Message($stream); + + if($headers !== null){ + foreach($headers as $name => $value){ + $message = $message->withAddedHeader($name, $value); + } + } + + return $this->addMessage($message, $fieldname, $filename, $setContentLength); + } + + /** + * Adds a MessageInterface + */ + public function addMessage( + MessageInterface $message, + string|null $fieldname = null, + string|null $filename = null, + bool|null $setContentLength = null, + ):static{ + $setContentLength ??= true; + + // hmm, we don't have a content-type, let's see if we can guess one + if(!$message->hasHeader('content-type')){ + // let it throw or ignore?? + $message = MessageUtil::setContentTypeHeader($message, $filename); + } + + // set Content-Disposition + $message = $this->setContentDispositionHeader($message, $fieldname, $filename); + + // set Content-Length + // @see https://github.com/guzzle/psr7/pull/581 + if($setContentLength === true){ + $this->messages[] = MessageUtil::setContentLengthHeader($message); + } + + return $this; + } + + /** + * Builds the multipart content from the given messages. + * + * If a MessageInterface is given, the body and content type header with the boundary will be set + * and the MessageInterface is returned; returns the StreamInterface with the content otherwise. + */ + public function build(MessageInterface|null $message = null):StreamInterface|MessageInterface{ + $this->multipartStream = $this->streamFactory->createStream(); + + foreach($this->messages as $part){ + // write boundary before each part + $this->multipartStream->write(sprintf("--%s\r\n", $this->boundary)); + // write content + $this->writeHeaders($part->getHeaders()); + $this->writeBody($part->getBody()); + } + + // write final boundary + $this->multipartStream->write(sprintf("--%s--\r\n", $this->boundary)); + // rewind stream!!! + $this->multipartStream->rewind(); + + // just return the stream + if($message === null){ + return $this->multipartStream; + } + + // write a proper multipart header to the given message and add the body + return $message + ->withHeader('Content-Type', sprintf('multipart/form-data; boundary="%s"', $this->boundary)) + ->withBody($this->multipartStream) + ; + } + + /** + * Parses and writes the headers from the given message to the multipart stream + */ + protected function writeHeaders(iterable $headers):void{ + $headers = HeaderUtil::normalize($headers); + // beautify + ksort($headers); + + foreach($headers as $name => $value){ + // skip unwanted headers + if(!str_starts_with($name, 'Content') && !str_starts_with($name, 'X-')){ + continue; + } + + // special rule to suppress the content type header + if($name === 'Content-Type' && $value === ''){ + continue; + } + + // write "Key: Value" followed by a newline + $this->multipartStream->write(sprintf("%s: %s\r\n", $name, $value)); + } + // end with newline + $this->multipartStream->write("\r\n"); + } + + /** + * Writes the content of the given StreamInterface to the multipart stream + */ + protected function writeBody(StreamInterface $body):void{ + + // rewind!!! + if($body->isSeekable()){ + $body->rewind(); + } + + StreamUtil::copyToStream($body, $this->multipartStream); + + // end with newline + $this->multipartStream->write("\r\n"); + } + + /** + * Sets the "Content-Disposition" header in the given MessageInterface if a name and/or filename are given + * + * If the header was already set on the message, this one will be used unmodified. + */ + protected function setContentDispositionHeader( + MessageInterface $message, + string|null $fieldname, + string|null $filename, + ):MessageInterface{ + // oh, you already set the header? okay - at your own risk! bye + if($message->hasHeader('Content-Disposition')){ + return $message; + } + + $contentDisposition = ['form-data']; + + if($fieldname !== null){ + $fieldname = trim($fieldname); + + if($fieldname === ''){ + throw new InvalidArgumentException('Invalid form field name'); + } + + $contentDisposition[] = sprintf('name="%s"', $fieldname); + } + + if($filename !== null){ + $contentDisposition[] = sprintf('filename="%s"', basename($filename)); + } + + if(count($contentDisposition) > 1){ + return $message->withHeader('Content-Disposition', implode('; ', $contentDisposition)); + } + + return $message; + } + +} diff --git a/src/Request.php b/src/Request.php new file mode 100644 index 0000000..8755e69 --- /dev/null +++ b/src/Request.php @@ -0,0 +1,148 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use Fig\Http\Message\RequestMethodInterface; +use Psr\Http\Message\{RequestInterface, StreamInterface, UriInterface}; +use InvalidArgumentException; +use function preg_match, strtoupper, trim; + +/** + * Implements a HTTP request message + */ +class Request extends Message implements RequestInterface, RequestMethodInterface{ + + protected UriInterface $uri; + protected string $method; + protected string|null $requestTarget = null; + + /** + * Request constructor. + */ + public function __construct(string $method, UriInterface|string $uri, StreamInterface|string|null $body = null){ + parent::__construct($body); + + $this->method = strtoupper(trim($method)); + + if($this->method === ''){ + throw new InvalidArgumentException('HTTP method must not be empty'); + } + + $this->uri = ($uri instanceof UriInterface) ? $uri : new Uri($uri); + + $this->updateHostFromUri(); + } + + /** + * @inheritDoc + */ + public function getRequestTarget():string{ + + if($this->requestTarget !== null){ + return $this->requestTarget; + } + + $target = $this->uri->getPath(); + $query = $this->uri->getQuery(); + + if($target === ''){ + $target = '/'; + } + + if($query !== ''){ + $target .= '?'.$query; + } + + return $target; + } + + /** + * @inheritDoc + */ + public function withRequestTarget(string $requestTarget):static{ + + if(preg_match('#\s#', $requestTarget)){ + throw new InvalidArgumentException('Invalid request target provided; cannot contain whitespace'); + } + + $this->requestTarget = $requestTarget; + + return $this; + } + + /** + * @inheritDoc + */ + public function getMethod():string{ + return $this->method; + } + + /** + * @inheritDoc + */ + public function withMethod(string $method):static{ + $method = strtoupper(trim($method)); + + if($method === ''){ + throw new InvalidArgumentException('HTTP method must not be empty'); + } + + $this->method = $method; + + return $this; + } + + /** + * @inheritDoc + */ + public function getUri():UriInterface{ + return $this->uri; + } + + /** + * @inheritDoc + */ + public function withUri(UriInterface $uri, bool $preserveHost = false):static{ + + if($uri !== $this->uri){ + $this->uri = $uri; + + if(!$preserveHost){ + $this->updateHostFromUri(); + } + } + + return $this; + } + + /** + * + */ + protected function updateHostFromUri():void{ + $host = $this->uri->getHost(); + + if($host !== ''){ + $port = $this->uri->getPort(); + + if($port !== null){ + $host .= ':'.$port; + } + + // Ensure Host is the first header. + // See: http://tools.ietf.org/html/rfc7230#section-5.4 + $this->headers = (['host' => ['name' => 'Host', 'value' => [$host]]] + $this->headers); + } + + } + +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..4e83eac --- /dev/null +++ b/src/Response.php @@ -0,0 +1,153 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use Fig\Http\Message\StatusCodeInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; +use function trim; + +/** + * Implements a HTTP response message + */ +class Response extends Message implements ResponseInterface, StatusCodeInterface{ + + /** + * Status codes and reason phrases + * + * @var array + */ + public const REASON_PHRASES = [ + //Informational 1xx + self::STATUS_CONTINUE => 'Continue', + self::STATUS_SWITCHING_PROTOCOLS => 'Switching Protocols', + self::STATUS_PROCESSING => 'Processing', + self::STATUS_EARLY_HINTS => 'Early Hints', + //Successful 2xx + self::STATUS_OK => 'OK', + self::STATUS_CREATED => 'Created', + self::STATUS_ACCEPTED => 'Accepted', + self::STATUS_NON_AUTHORITATIVE_INFORMATION => 'Non-Authoritative Information', + self::STATUS_NO_CONTENT => 'No Content', + self::STATUS_RESET_CONTENT => 'Reset Content', + self::STATUS_PARTIAL_CONTENT => 'Partial Content', + self::STATUS_MULTI_STATUS => 'Multi-Status', + self::STATUS_ALREADY_REPORTED => 'Already Reported', + self::STATUS_IM_USED => 'IM Used', + //Redirection 3xx + self::STATUS_MULTIPLE_CHOICES => 'Multiple Choices', + self::STATUS_MOVED_PERMANENTLY => 'Moved Permanently', + self::STATUS_FOUND => 'Found', + self::STATUS_SEE_OTHER => 'See Other', + self::STATUS_NOT_MODIFIED => 'Not Modified', + self::STATUS_USE_PROXY => 'Use Proxy', + self::STATUS_RESERVED => 'Reserved', + self::STATUS_TEMPORARY_REDIRECT => 'Temporary Redirect', + self::STATUS_PERMANENT_REDIRECT => 'Permanent Redirect', + //Client Error 4xx + self::STATUS_BAD_REQUEST => 'Bad Request', + self::STATUS_UNAUTHORIZED => 'Unauthorized', + self::STATUS_PAYMENT_REQUIRED => 'Payment Required', + self::STATUS_FORBIDDEN => 'Forbidden', + self::STATUS_NOT_FOUND => 'Not Found', + self::STATUS_METHOD_NOT_ALLOWED => 'Method Not Allowed', + self::STATUS_NOT_ACCEPTABLE => 'Not Acceptable', + self::STATUS_PROXY_AUTHENTICATION_REQUIRED => 'Proxy Authentication Required', + self::STATUS_REQUEST_TIMEOUT => 'Request Timeout', + self::STATUS_CONFLICT => 'Conflict', + self::STATUS_GONE => 'Gone', + self::STATUS_LENGTH_REQUIRED => 'Length Required', + self::STATUS_PRECONDITION_FAILED => 'Precondition Failed', + self::STATUS_PAYLOAD_TOO_LARGE => 'Request Entity Too Large', + self::STATUS_URI_TOO_LONG => 'Request-URI Too Long', + self::STATUS_UNSUPPORTED_MEDIA_TYPE => 'Unsupported Media Type', + self::STATUS_RANGE_NOT_SATISFIABLE => 'Requested Range Not Satisfiable', + self::STATUS_EXPECTATION_FAILED => 'Expectation Failed', + self::STATUS_IM_A_TEAPOT => 'I\'m a teapot', + 420 => 'Enhance Your Calm', // https://http.cat/420 + self::STATUS_MISDIRECTED_REQUEST => 'Misdirected Request', + self::STATUS_UNPROCESSABLE_ENTITY => 'Unprocessable Entity', + self::STATUS_LOCKED => 'Locked', + self::STATUS_FAILED_DEPENDENCY => 'Failed Dependency', + self::STATUS_TOO_EARLY => 'Too Early', + self::STATUS_UPGRADE_REQUIRED => 'Upgrade Required', + self::STATUS_PRECONDITION_REQUIRED => 'Precondition Required', + self::STATUS_TOO_MANY_REQUESTS => 'Too Many Requests', + self::STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE => 'Request Header Fields Too Large', + 444 => 'Connection Closed Without Response', + self::STATUS_UNAVAILABLE_FOR_LEGAL_REASONS => 'Unavailable For Legal Reasons', + 499 => 'Client Closed Request', + //Server Error 5xx + self::STATUS_INTERNAL_SERVER_ERROR => 'Internal Server Error', + self::STATUS_NOT_IMPLEMENTED => 'Not Implemented', + self::STATUS_BAD_GATEWAY => 'Bad Gateway', + self::STATUS_SERVICE_UNAVAILABLE => 'Service Unavailable', + self::STATUS_GATEWAY_TIMEOUT => 'Gateway Timeout', + self::STATUS_VERSION_NOT_SUPPORTED => 'HTTP Version Not Supported', + self::STATUS_VARIANT_ALSO_NEGOTIATES => 'Variant Also Negotiates', + self::STATUS_INSUFFICIENT_STORAGE => 'Insufficient Storage', + self::STATUS_LOOP_DETECTED => 'Loop Detected', + self::STATUS_NOT_EXTENDED => 'Not Extended', + self::STATUS_NETWORK_AUTHENTICATION_REQUIRED => 'Network Authentication Required', + 599 => 'Network Connect Timeout Error', + ]; + + protected string $reasonPhrase; + protected int $statusCode; + + /** + * Response constructor. + */ + public function __construct(int|null $status = null, string|null $reason = null, StreamInterface|string|null $body = null){ + parent::__construct($body); + + $this->statusCode = ($status ?? $this::STATUS_OK); + $this->reasonPhrase = ($reason ?? $this->getReasonPhraseFromStatusCode($this->statusCode)); + } + + /** + * @inheritDoc + */ + public function getStatusCode():int{ + return $this->statusCode; + } + + /** + * @inheritDoc + */ + public function withStatus(int $code, string $reasonPhrase = ''):static{ + $this->reasonPhrase = trim($reasonPhrase); + $this->statusCode = $code; + + if($this->reasonPhrase === ''){ + $this->reasonPhrase = $this->getReasonPhraseFromStatusCode($this->statusCode); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function getReasonPhrase():string{ + return $this->reasonPhrase; + } + + /** + * Get the reason phrase for the given status code, returns an empty string if no matching phrase is found + */ + protected function getReasonPhraseFromStatusCode(int $status):string{ + return ($this::REASON_PHRASES[$status] ?? ''); + } + +} diff --git a/src/ServerRequest.php b/src/ServerRequest.php new file mode 100644 index 0000000..68c568d --- /dev/null +++ b/src/ServerRequest.php @@ -0,0 +1,156 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use Psr\Http\Message\{ServerRequestInterface, UriInterface}; +use InvalidArgumentException; +use function array_key_exists, is_array, is_object; + +/** + * Implements a server-side incoming HTTP request + */ +class ServerRequest extends Request implements ServerRequestInterface{ + + protected array $serverParams; + protected array $cookieParams = []; + protected array $queryParams = []; + protected array $attributes = []; + protected array $uploadedFiles = []; + protected array|object|null $parsedBody = null; + + /** + * ServerRequest constructor. + */ + public function __construct(string $method, UriInterface|string $uri, array|null $serverParams = null){ + parent::__construct($method, $uri); + + $this->serverParams = ($serverParams ?? []); + } + + /** + * @inheritDoc + */ + public function getServerParams():array{ + return $this->serverParams; + } + + /** + * @inheritDoc + */ + public function getCookieParams():array{ + return $this->cookieParams; + } + + /** + * @inheritDoc + */ + public function withCookieParams(array $cookies):static{ + $this->cookieParams = $cookies; + + return $this; + } + + /** + * @inheritDoc + */ + public function getQueryParams():array{ + return $this->queryParams; + } + + /** + * @inheritDoc + */ + public function withQueryParams(array $query):static{ + $this->queryParams = $query; + + return $this; + } + + /** + * @inheritDoc + */ + public function getUploadedFiles():array{ + return $this->uploadedFiles; + } + + /** + * @inheritDoc + */ + public function withUploadedFiles(array $uploadedFiles):static{ + $this->uploadedFiles = $uploadedFiles; + + return $this; + } + + /** + * @inheritDoc + */ + public function getParsedBody():array|object|null{ + return $this->parsedBody; + } + + /** + * @inheritDoc + */ + public function withParsedBody(mixed $data):static{ + + if($data !== null && !is_object($data) && !is_array($data)){ + throw new InvalidArgumentException('parsed body value must be an array, object or null'); + } + + $this->parsedBody = $data; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAttributes():array{ + return $this->attributes; + } + + /** + * @inheritDoc + */ + public function getAttribute(string $name, mixed $default = null):mixed{ + + if(!array_key_exists($name, $this->attributes)){ + return $default; + } + + return $this->attributes[$name]; + } + + /** + * @inheritDoc + */ + public function withAttribute(string $name, mixed $value):static{ + $this->attributes[$name] = $value; + + return $this; + } + + /** + * @inheritDoc + */ + public function withoutAttribute(string $name):static{ + + if(array_key_exists($name, $this->attributes)){ + unset($this->attributes[$name]); + } + + return $this; + } + +} diff --git a/src/Stream.php b/src/Stream.php new file mode 100644 index 0000000..032580d --- /dev/null +++ b/src/Stream.php @@ -0,0 +1,296 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use chillerlan\HTTP\Utils\StreamUtil; +use Psr\Http\Message\StreamInterface; +use InvalidArgumentException, RuntimeException; +use function clearstatcache, fclose, feof, fread, fstat, ftell, fwrite, is_resource, stream_get_meta_data; +use const SEEK_SET; + +/** + * Implements a data stream object + */ +class Stream implements StreamInterface{ + + /** @var resource|null */ + protected $stream = null; + protected bool $seekable; + protected bool $readable; + protected bool $writable; + protected string|null $uri = null; + protected int|null $size = null; + + /** + * Stream constructor. + * + * @param resource $stream + */ + public function __construct($stream){ + + if(!is_resource($stream)){ + throw new InvalidArgumentException('Stream must be a resource'); + } + + $this->stream = $stream; + $meta = $this->getMetadata(); + $mode = ($meta['mode'] ?? ''); + $this->seekable = ($meta['seekable'] ?? false); + $this->readable = StreamUtil::modeAllowsRead($mode); + $this->writable = StreamUtil::modeAllowsWrite($mode); + $this->uri = ($meta['uri'] ?? null); + } + + /** + * Closes the stream when the destructed + * + * @return void + */ + public function __destruct(){ + $this->close(); + } + + /** + * @inheritDoc + */ + public function __toString():string{ + + if(!is_resource($this->stream)){ + return ''; + } + + if($this->isSeekable()){ + $this->seek(0); + } + + return $this->getContents(); + } + + /** + * @inheritDoc + */ + public function close():void{ + + if(is_resource($this->stream)){ + fclose($this->stream); + } + + $this->detach(); + } + + /** + * @inheritDoc + */ + public function detach(){ + $oldResource = $this->stream; + + $this->stream = null; + $this->size = null; + $this->uri = null; + $this->readable = false; + $this->writable = false; + $this->seekable = false; + + return $oldResource; + } + + /** + * @inheritDoc + */ + public function getSize():int|null{ + + if(!is_resource($this->stream)){ + return null; + } + + // Clear the stat cache if the stream has a URI + if($this->uri){ + clearstatcache(true, $this->uri); + } + + $stats = fstat($this->stream); + + if(isset($stats['size'])){ + $this->size = $stats['size']; + + return $this->size; + } + + if($this->size !== null){ + return $this->size; + } + + return null; // @codeCoverageIgnore + } + + /** + * @inheritDoc + */ + public function tell():int{ + + if(!is_resource($this->stream)){ + throw new RuntimeException('Invalid stream'); // @codeCoverageIgnore + } + + $result = ftell($this->stream); + + if($result === false){ + throw new RuntimeException('Unable to determine stream position'); // @codeCoverageIgnore + } + + return $result; + } + + /** + * @inheritDoc + */ + public function eof():bool{ + return !$this->stream || feof($this->stream); + } + + /** + * @inheritDoc + */ + public function isSeekable():bool{ + return $this->seekable; + } + + /** + * @inheritDoc + */ + public function seek(int $offset, int $whence = SEEK_SET):void{ + + if(!is_resource($this->stream)){ + throw new RuntimeException('Invalid stream'); // @codeCoverageIgnore + } + + if(!$this->seekable){ + throw new RuntimeException('Stream is not seekable'); + } + + if(fseek($this->stream, $offset, $whence) === -1){ + throw new RuntimeException('Unable to seek to stream position '.$offset.' with whence '.$whence); + } + + } + + /** + * @inheritDoc + */ + public function rewind():void{ + $this->seek(0); + } + + /** + * @inheritDoc + */ + public function isWritable():bool{ + return $this->writable; + } + + /** + * @inheritDoc + */ + public function write(string $string):int{ + + if(!is_resource($this->stream)){ + throw new RuntimeException('Invalid stream'); // @codeCoverageIgnore + } + + if(!$this->writable){ + throw new RuntimeException('Cannot write to a non-writable stream'); + } + + // We can't know the size after writing anything + $this->size = null; + $result = fwrite($this->stream, $string); + + if($result === false){ + throw new RuntimeException('Unable to write to stream'); // @codeCoverageIgnore + } + + return $result; + } + + /** + * @inheritDoc + */ + public function isReadable():bool{ + return $this->readable; + } + + /** + * @inheritDoc + */ + public function read(int $length):string{ + + if(!is_resource($this->stream)){ + throw new RuntimeException('Invalid stream'); // @codeCoverageIgnore + } + + if(!$this->readable){ + throw new RuntimeException('Cannot read from non-readable stream'); + } + + if($length < 0){ + throw new RuntimeException('Length parameter cannot be negative'); + } + + if($length === 0){ + return ''; + } + + $string = fread($this->stream, $length); + + if($string === false){ + throw new RuntimeException('Unable to read from stream'); // @codeCoverageIgnore + } + + return $string; + } + + /** + * @inheritDoc + */ + public function getContents():string{ + + if(!is_resource($this->stream)){ + throw new RuntimeException('Invalid stream'); // @codeCoverageIgnore + } + + if(!$this->readable){ + throw new RuntimeException('Cannot read from non-readable stream'); + } + + return StreamUtil::tryGetContents($this->stream); + } + + /** + * @inheritDoc + */ + public function getMetadata(string|null $key = null):mixed{ + + if(!is_resource($this->stream)){ + return ($key) ? null : []; + } + + $meta = stream_get_meta_data($this->stream); + + if($key === null){ + return $meta; + } + + return ($meta[$key] ?? null); + } + +} diff --git a/src/UploadedFile.php b/src/UploadedFile.php new file mode 100644 index 0000000..64c4afd --- /dev/null +++ b/src/UploadedFile.php @@ -0,0 +1,188 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use Psr\Http\Message\{StreamFactoryInterface, StreamInterface, UploadedFileInterface}; +use InvalidArgumentException, RuntimeException; +use function in_array, is_file, is_string, is_writable, move_uploaded_file, php_sapi_name, rename; +use const UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION, UPLOAD_ERR_FORM_SIZE, UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_NO_FILE, UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_OK, UPLOAD_ERR_PARTIAL; + +/** + * Implements an uploaded file object + */ +class UploadedFile implements UploadedFileInterface{ + + /** @var int[] */ + public const UPLOAD_ERRORS = [ + UPLOAD_ERR_OK, + UPLOAD_ERR_INI_SIZE, + UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_PARTIAL, + UPLOAD_ERR_NO_FILE, + UPLOAD_ERR_NO_TMP_DIR, + UPLOAD_ERR_CANT_WRITE, + UPLOAD_ERR_EXTENSION, + ]; + + protected string|null $file = null; + protected StreamInterface|null $stream; + protected bool $moved = false; + + /** + * @throws \InvalidArgumentException + */ + public function __construct( + mixed $file, + protected int $size, + protected int $error = UPLOAD_ERR_OK, + protected string|null $filename = null, + protected string|null $mediaType = null, + protected StreamFactoryInterface $streamFactory = new HTTPFactory, + ){ + + if(!in_array($error, $this::UPLOAD_ERRORS, true)){ + throw new InvalidArgumentException('Invalid error status for UploadedFile'); + } + + if($this->error === UPLOAD_ERR_OK){ + + if(is_string($file)){ + $this->file = $file; + } + else{ + $this->stream = HTTPFactory::createStreamFromSource($file); + } + + } + + } + + /** + * @inheritDoc + */ + public function getStream():StreamInterface{ + + $this->validateActive(); + + if($this->stream instanceof StreamInterface){ + return $this->stream; + } + + if(is_file($this->file)){ + return $this->streamFactory->createStreamFromFile($this->file, 'r+'); + } + + return $this->streamFactory->createStream($this->file); + } + + /** + * @inheritDoc + */ + public function moveTo(string $targetPath):void{ + + $this->validateActive(); + + if(empty($targetPath)){ + throw new InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); + } + + if(!is_writable($targetPath)){ + throw new RuntimeException('Directory is not writable: '.$targetPath); + } + + if($this->file !== null){ + $this->moved = php_sapi_name() === 'cli' + ? rename($this->file, $targetPath) + : move_uploaded_file($this->file, $targetPath); + } + else{ + $this->copyToStream($this->streamFactory->createStreamFromFile($targetPath, 'r+')); + $this->moved = true; + } + + if($this->moved === false){ + throw new RuntimeException('Uploaded file could not be moved to '.$targetPath); // @codeCoverageIgnore + } + + } + + /** + * @inheritDoc + */ + public function getSize():int|null{ + return $this->size; + } + + /** + * @inheritDoc + */ + public function getError():int{ + return $this->error; + } + + /** + * @inheritDoc + */ + public function getClientFilename():string|null{ + return $this->filename; + } + + /** + * @inheritDoc + */ + public function getClientMediaType():string|null{ + return $this->mediaType; + } + + /** + * @throws RuntimeException if is moved or not ok + */ + protected function validateActive():void{ + + if($this->error !== UPLOAD_ERR_OK){ + throw new RuntimeException('Cannot retrieve stream due to upload error'); + } + + if($this->moved){ + throw new RuntimeException('Cannot retrieve stream after it has already been moved'); + } + + } + + /** + * Copy the contents of a stream into another stream until the given number + * of bytes have been read. + * + * @author Michael Dowling and contributors to guzzlehttp/psr7 + * + * @throws \RuntimeException on error + */ + protected function copyToStream(StreamInterface $dest):void{ + $source = $this->getStream(); + + if($source->isSeekable()){ + $source->rewind(); + } + + while(!$source->eof()){ + + if(!$dest->write($source->read(1048576))){ + break; // @codeCoverageIgnore + } + + } + + } + +} diff --git a/src/Uri.php b/src/Uri.php new file mode 100644 index 0000000..7883c79 --- /dev/null +++ b/src/Uri.php @@ -0,0 +1,383 @@ + + * @copyright 2018 smiley + * @license MIT + * + * @noinspection RegExpUnnecessaryNonCapturingGroup, RegExpRedundantEscape + */ + +declare(strict_types=1); + +namespace chillerlan\HTTP\Psr7; + +use chillerlan\HTTP\Utils\UriUtil; +use Psr\Http\Message\UriInterface; +use InvalidArgumentException; +use function explode, filter_var, is_array, is_string, ltrim, mb_strtolower, preg_match, + preg_replace_callback, property_exists, rawurlencode, str_contains, str_starts_with, trim; +use const FILTER_FLAG_IPV6, FILTER_VALIDATE_IP; + +/** + * Implements an URI object + * + * @see https://datatracker.ietf.org/doc/html/rfc3986 + * @see https://datatracker.ietf.org/doc/html/rfc7320 + * @see https://datatracker.ietf.org/doc/html/rfc8820 + */ +class Uri implements UriInterface{ + + /** + * Percent encoded + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.1 + */ + protected const CHAR_PERCENT_HEX = '%(?![a-fA-F0-9]{2})'; + + /** + * Generic delimiters for use in a regex. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 + */ + protected const CHAR_GEN_DELIMS = ':\/\?#\[\]@'; + + /** + * Sub delimiters for use in a regex. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 + */ + protected const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; + + /** + * Unreserved characters for use in a regex. + * + * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + */ + protected const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; + + protected string $scheme = ''; + protected string $user = ''; + protected string $pass = ''; + protected string $host = ''; + protected int|null $port = null; + protected string $path = ''; + protected string $query = ''; + protected string $fragment = ''; + + /** + * Uri constructor. + * + * @throws \InvalidArgumentException + */ + public function __construct(string|array|null $uri = null){ + + if($uri !== null){ + + if(is_string($uri)){ + $uri = UriUtil::parseUrl($uri); + } + + if(!is_array($uri)){ + throw new InvalidArgumentException('Unable to parse URI'); + } + + $this->parseUriParts($uri); + } + + } + + /** + * @inheritDoc + * @throws \InvalidArgumentException + */ + public function __toString():string{ + + if(empty($this->scheme) && str_contains(explode('/', $this->path, 2)[0], ':')){ + throw new InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon'); + } + + $uri = ''; + $authority = $this->getAuthority(); + $path = $this->path; + + if($this->scheme !== ''){ + $uri .= $this->scheme.':'; + } + + // fix "file" scheme (see Guzzle) + if($authority !== '' || $this->scheme === 'file'){ + $uri .= '//'.$authority; + } + + // If the path is rootless and an authority is present, the path MUST be prefixed by "/" + if($authority !== '' && $path !== '' && !str_starts_with($path, '/')){ + $path = '/'.$path; + } + // If the path is starting with more than one "/", the starting slashes MUST be reduced to one. + elseif($authority === '' && str_starts_with($path, '//')){ + $path = '/'.ltrim($path, '/'); + } + + $uri .= $path; + + if($this->query !== ''){ + $uri .= '?'.$this->query; + } + + if($this->fragment !== ''){ + $uri .= '#'.$this->fragment; + } + + return $uri; + } + + /* + * Getters + */ + + /** + * @inheritDoc + */ + public function getScheme():string{ + return $this->scheme; + } + + /** + * @inheritDoc + */ + public function getUserInfo():string{ + $userinfo = $this->user; + + if($this->pass !== ''){ + $userinfo .= ':'.$this->pass; + } + + return $userinfo; + } + + /** + * @inheritDoc + */ + public function getHost():string{ + return $this->host; + } + + /** + * @inheritDoc + */ + public function getPort():int|null{ + return $this->port; + } + + /** + * @inheritDoc + */ + public function getAuthority():string{ + $authority = $this->host; + $userInfo = $this->getUserInfo(); + + if($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')){ + $authority = 'localhost'; + } + + if($userInfo !== ''){ + $authority = $userInfo.'@'.$authority; + } + + if($this->port !== null){ + $authority .= ':'.$this->port; + } + + return $authority; + } + + /** + * @inheritDoc + */ + public function getPath():string{ + return $this->path; + } + + /** + * @inheritDoc + */ + public function getQuery():string{ + return $this->query; + } + + /** + * @inheritDoc + */ + public function getFragment():string{ + return $this->fragment; + } + + /* + * Setters + */ + + /** + * @inheritDoc + */ + public function withScheme(string $scheme):static{ + return $this->parseUriParts(['scheme' => $scheme]); + } + + /** + * @inheritDoc + */ + public function withUserInfo(string $user, string|null $password = null):static{ + return $this->parseUriParts(['user' => $user, 'pass' => ($password ?? '')]); + } + + /** + * @inheritDoc + */ + public function withHost(string $host):static{ + return $this->parseUriParts(['host' => $host]); + } + + /** + * @inheritDoc + */ + public function withPort(int|null $port):static{ + return $this->parseUriParts(['port' => $port]); + } + + /** + * @inheritDoc + */ + public function withPath(string $path):static{ + return $this->parseUriParts(['path' => $path]); + } + + /** + * @inheritDoc + */ + public function withQuery(string $query):static{ + return $this->parseUriParts(['query' => $query]); + } + + /** + * @inheritDoc + */ + public function withFragment(string $fragment):static{ + return $this->parseUriParts(['fragment' => $fragment]); + } + + /* + * Filters + */ + + /** + * @throws \InvalidArgumentException + */ + protected function filterScheme(string $scheme):string{ + $scheme = mb_strtolower(trim($scheme)); + + if(!preg_match('/^[a-z0-9\+\-\.]*$/', $scheme)){ + throw new InvalidArgumentException('scheme contains illegal characters'); + } + + return $scheme; + } + + /** + * + */ + protected function filterUserInfo(string $userOrPass):string{ + return $this->replaceChars( + $userOrPass, + '/(?:['.self::CHAR_GEN_DELIMS.self::CHAR_SUB_DELIMS.']+|'.self::CHAR_PERCENT_HEX.')/', + ); + } + + /** + * + */ + protected function filterHost(string $host):string{ + $filteredIPv6 = filter_var(trim($host, '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + + if($filteredIPv6 !== false){ + $host = '['.$filteredIPv6.']'; + } + + return mb_strtolower($host); + } + + /** + * @throws \InvalidArgumentException + */ + protected function filterPort(int|null $port):int|null{ + + if($port === null){ + return null; + } + + if($port >= 0 && $port <= 0xffff){ + return $port; + } + + throw new InvalidArgumentException('invalid port: '.$port); + } + + /** + * + */ + protected function filterPath(string $path):string{ + return $this->replaceChars( + $path, + '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/]++|'.self::CHAR_PERCENT_HEX.')/', + ); + } + + /** + * + */ + protected function filterQueryOrFragment(string $queryOrFragment):string{ + return $this->replaceChars( + $queryOrFragment, + '/(?:[^'.self::CHAR_UNRESERVED.self::CHAR_SUB_DELIMS.'%:@\/\?]++|'.self::CHAR_PERCENT_HEX.')/', + ); + } + + /** + * + */ + protected function replaceChars(string $str, string $regex):string{ + return preg_replace_callback($regex, fn(array $match):string => rawurlencode($match[0]), $str); + } + + /** + * + */ + protected function parseUriParts(array $parts):static{ + + foreach($parts as $part => $value){ + + if(!property_exists($this, $part)){ + continue; + } + + $this->{$part} = match($part){ + 'user', 'pass' => $this->filterUserInfo($value), + 'scheme' => $this->filterScheme($value), + 'host' => $this->filterHost($value), + 'port' => $this->filterPort($value), + 'path' => $this->filterPath($value), + 'query', 'fragment' => $this->filterQueryOrFragment($value), + }; + + } + + if(UriUtil::isDefaultPort($this)){ + $this->port = null; + } + + return $this; + } + +} diff --git a/tests/DummyStreamTest.php b/tests/DummyStreamTest.php new file mode 100644 index 0000000..213391b --- /dev/null +++ b/tests/DummyStreamTest.php @@ -0,0 +1,93 @@ + + * @copyright 2023 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\DummyStream; +use PHPUnit\Framework\TestCase; + +/** + * + */ +class DummyStreamTest extends TestCase{ + use FactoryTrait; + + protected function setUp():void{ + $this->initFactories(); + } + + public function testDefaultStream():void{ + $dummy = new DummyStream; + + $this::assertTrue($dummy->isReadable()); + $this::assertTrue($dummy->isWritable()); + $this::assertTrue($dummy->isSeekable()); + $this::assertSame(4, $dummy->write('data')); + $this::assertSame('php://temp', $dummy->getMetadata('uri')); + $this::assertIsArray($dummy->getMetadata()); + $this::assertSame(4, $dummy->getSize()); + $this::assertSame(4, $dummy->tell()); + $this::assertFalse($dummy->eof()); + $dummy->seek(2); + $this::assertSame('ta', $dummy->read(2)); + $dummy->rewind(); + $this::assertSame('data', $dummy->getContents()); + $this::assertSame('data', (string)$dummy); + $dummy->close(); + } + + public function testProxiesToFunction():void{ + $dummy = new DummyStream; + $dummy->dummyOverrideMethod('read', function(int $length):string{ + TestCase::assertSame(3, $length); + + return 'foo'; + }); + + $this::assertSame('foo', $dummy->read(3)); + } + + public function testCanCloseOnDestruct():void{ + $called = false; + $dummy = new DummyStream; + + $dummy->dummyOverrideMethod('close', function() use (&$called):void{ + $called = true; + }); + + unset($dummy); + + $this::assertTrue($called); + } + + public function testDecoratesWithCustomizations(): void{ + $called = false; + + $a = $this->streamFactory->createStream('foo'); + + $b = new DummyStream($a, [ + 'read' => function(int $length) use (&$called, $a):string{ + $called = true; + + return $a->read($length); + }, + ]); + + $b->rewind(); + + $this::assertSame('foo', $b->read(3)); + $this::assertTrue($called); + } + +} diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php new file mode 100644 index 0000000..8ecc0d1 --- /dev/null +++ b/tests/FactoryTrait.php @@ -0,0 +1,74 @@ + + * @copyright 2021 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Utils\ServerUtil; +use Psr\Http\Message\{ + RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, + StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface +}; +use Exception; +use function class_exists, constant, defined, sprintf; + +/** + * + */ +trait FactoryTrait{ + + private array $FACTORIES = [ + 'requestFactory' => 'REQUEST_FACTORY', + 'responseFactory' => 'RESPONSE_FACTORY', + 'serverRequestFactory' => 'SERVER_REQUEST_FACTORY', + 'streamFactory' => 'STREAM_FACTORY', + 'uploadedFileFactory' => 'UPLOADED_FILE_FACTORY', + 'uriFactory' => 'URI_FACTORY', + ]; + + protected RequestFactoryInterface $requestFactory; + protected ResponseFactoryInterface $responseFactory; + protected ServerRequestFactoryInterface $serverRequestFactory; + protected StreamFactoryInterface $streamFactory; + protected UploadedFileFactoryInterface $uploadedFileFactory; + protected UriFactoryInterface $uriFactory; + protected ServerUtil $server; + + /** + * @throws \Exception + */ + protected function initFactories():void{ + + foreach($this->FACTORIES as $property => $const){ + + if(!defined($const)){ + throw new Exception(sprintf('constant "%s" not defined -> see phpunit.xml', $const)); + } + + $class = constant($const); + + if(!class_exists($class)){ + throw new Exception(sprintf('invalid class: "%s"', $class)); + } + + $this->{$property} = new $class; + } + + $this->server = new ServerUtil( + $this->serverRequestFactory, + $this->uriFactory, + $this->uploadedFileFactory, + $this->streamFactory + ); + + } + +} diff --git a/tests/FactoryUtilsTest.php b/tests/FactoryUtilsTest.php new file mode 100644 index 0000000..adfdc26 --- /dev/null +++ b/tests/FactoryUtilsTest.php @@ -0,0 +1,74 @@ + + * @copyright 2019 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\HTTPFactory; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamInterface; +use InvalidArgumentException, stdClass; +use function fopen, fseek, fwrite, simplexml_load_string; + +/** + * + */ +class FactoryUtilsTest extends TestCase{ + use FactoryTrait; + + protected function setUp():void{ + $this->initFactories(); + } + + public function testCreateStream():void{ + $stream = HTTPFactory::createStreamFromString('test'); + + $this::assertInstanceOf(Streaminterface::class, $stream); + $this::assertSame('test', $stream->getContents()); + } + + public function testCreateStreamInvalidModeException():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid mode for writing'); + + HTTPFactory::createStreamFromString('test', 'r'); + } + + public static function streamInputProvider():array{ + $fh = fopen('php://temp', 'r+'); + + fwrite($fh, 'resourcetest'); + fseek($fh, 0); + + $xml = simplexml_load_string('bar'); + + return [ + 'string' => ['stringtest', 'stringtest'], + 'resource' => [$fh, 'resourcetest'], + 'streaminterface' => [HTTPFactory::createStreamFromString('streaminterfacetest'), 'streaminterfacetest'], + 'tostring' => [$xml->foo, 'bar'], + ]; + } + + #[DataProvider('streamInputProvider')] + public function testCreateStreamFromInput(mixed $input, string $content):void{ + $this::assertSame($content, HTTPFactory::createStreamFromSource($input)->getContents()); + } + + public function testCreateStreamFromInputException():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid resource type: object'); + + HTTPFactory::createStreamFromSource(new stdClass); + } + +} diff --git a/tests/MessageTest.php b/tests/MessageTest.php new file mode 100644 index 0000000..442eed7 --- /dev/null +++ b/tests/MessageTest.php @@ -0,0 +1,167 @@ + + * @copyright 2023 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\Message; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamInterface; + +/** + * + */ +class MessageTest extends TestCase{ + + public function testNullBody():void{ + $message = new Message; + + $this::assertInstanceOf(StreamInterface::class, $message->getBody()); + $this::assertSame('', (string)$message->getBody()); + } + + public function testReturnsEmptyHeadersArray():void{ + $message = new Message; + + $this::assertEmpty($message->getHeaders()); + } + + public function testWithHeader():void{ + $message = (new Message)->withHeader('Foo', 'Bar'); + + $this::assertSame(['Foo' => ['Bar']], $message->getHeaders()); + + $message = $message->withHeader('baZ', 'Bam'); + + $this::assertSame(['Foo' => ['Bar'], 'baZ' => ['Bam']], $message->getHeaders()); + $this::assertSame('Bam', $message->getHeaderLine('baz')); + $this::assertSame(['Bam'], $message->getHeader('baz')); + } + + public function testWithHeaderAsArray():void{ + $message = (new Message)->withHeader('Foo', 'Bar'); + + $this::assertSame(['Foo' => ['Bar']], $message->getHeaders()); + + $message = $message->withHeader('baZ', ['Bam', 'Bar']); + + $this::assertSame(['Foo' => ['Bar'], 'baZ' => ['Bam', 'Bar']], $message->getHeaders()); + $this::assertSame('Bam, Bar', $message->getHeaderLine('baz')); + $this::assertSame(['Bam', 'Bar'], $message->getHeader('baz')); + } + + public function testWithHeaderReplacesDifferentCase():void{ + $message = (new Message)->withHeader('Foo', 'Bar'); + + $this::assertSame(['Foo' => ['Bar']], $message->getHeaders()); + + $message = $message->withHeader('foO', 'Bam'); + + $this::assertSame(['foO' => ['Bam']], $message->getHeaders()); + $this::assertSame('Bam', $message->getHeaderLine('foo')); + $this::assertSame(['Bam'], $message->getHeader('foo')); + } + + public function testWithAddedHeader():void{ + $message = (new Message)->withHeader('Foo', 'Bar'); + + $this::assertSame(['Foo' => ['Bar']], $message->getHeaders()); + + $message = $message->withAddedHeader('foO', 'Baz'); + + $this::assertSame(['Foo' => ['Bar', 'Baz']], $message->getHeaders()); + $this::assertSame('Bar, Baz', $message->getHeaderLine('foo')); + $this::assertSame(['Bar', 'Baz'], $message->getHeader('foo')); + } + + public function testWithAddedHeaderAsArray():void{ + $message = (new Message)->withHeader('Foo', 'Bar'); + + $this::assertSame(['Foo' => ['Bar']], $message->getHeaders()); + + $message = $message->withAddedHeader('foO', ['Baz', 'Bam']); + + $this::assertSame(['Foo' => ['Bar', 'Baz', 'Bam']], $message->getHeaders()); + $this::assertSame('Bar, Baz, Bam', $message->getHeaderLine('foo')); + $this::assertSame(['Bar', 'Baz', 'Bam'], $message->getHeader('foo')); + } + + public function testWithAddedHeaderThatDoesNotExist():void{ + $message = (new Message)->withHeader('Foo', 'Bar'); + + $this::assertSame(['Foo' => ['Bar']], $message->getHeaders()); + + $message = $message->withAddedHeader('nEw', 'Baz'); + + $this::assertSame(['Foo' => ['Bar'], 'nEw' => ['Baz']], $message->getHeaders()); + $this::assertSame('Baz', $message->getHeaderLine('new')); + $this::assertSame(['Baz'], $message->getHeader('new')); + } + + public function testWithoutHeaderThatExists():void{ + + $message = (new Message) + ->withHeader('Foo', 'Bar') + ->withHeader('Baz', 'Bam') + ; + + $this::assertTrue($message->hasHeader('foo')); + $this::assertSame(['Foo' => ['Bar'], 'Baz' => ['Bam']], $message->getHeaders()); + + $message = $message->withoutHeader('foO'); + + $this::assertFalse($message->hasHeader('foo')); + $this::assertSame(['Baz' => ['Bam']], $message->getHeaders()); + } + + public function testWithoutHeaderThatDoesNotExist():void{ + + $message = (new Message) + ->withHeader('Baz', 'Bam') + ->withoutHeader('foO') + ; + + $this::assertSame($message, $message); + $this::assertFalse($message->hasHeader('foo')); + $this::assertSame(['Baz' => ['Bam']], $message->getHeaders()); + } + + public function testHeaderValuesAreTrimmed():void{ + $message1 = (new Message)->withHeader('Bar', " \t \tFoo\t \t "); + $message2 = (new Message)->withAddedHeader('Bar', " \t \tFoo\t \t "); + + foreach([$message1, $message2] as $message){ + $this::assertSame(['Bar' => ['Foo']], $message->getHeaders()); + $this::assertSame('Foo', $message->getHeaderLine('Bar')); + $this::assertSame(['Foo'], $message->getHeader('Bar')); + } + } + + public function testSupportNumericHeaderValues():void{ + /** @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal */ + $message = (new Message)->withHeader('Content-Length', 69); + + $this::assertSame(['Content-Length' => ['69']], $message->getHeaders()); + $this::assertSame('69', $message->getHeaderLine('Content-Length')); + } + + public function testHeaderNameAndValueDoesNotContainCRLF():void{ + + $message = (new Message) + ->withHeader("\rF\n\ro\r\n\r\no", "\rB\r\n\r\na\n\rr") + ->withAddedHeader("\rB\r\n\r\na\n\rr", "\rF\n\ro\r\n\r\no") + ; + + $this::assertSame('Bar', $message->getHeaderLine('Foo')); + $this::assertSame('Foo', $message->getHeaderLine('Bar')); + } + +} diff --git a/tests/MultipartStreamBuilderTest.php b/tests/MultipartStreamBuilderTest.php new file mode 100644 index 0000000..8857984 --- /dev/null +++ b/tests/MultipartStreamBuilderTest.php @@ -0,0 +1,372 @@ + + * @copyright 2023 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\MultipartStreamBuilder; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\MessageInterface; +use InvalidArgumentException; + +/** + * + */ +class MultipartStreamBuilderTest extends TestCase{ + use FactoryTrait; + + protected MultipartStreamBuilder $multipartStreamBuilder; + + protected function setUp():void{ + $this->initFactories(); + + $this->multipartStreamBuilder = new MultipartStreamBuilder($this->streamFactory); + } + + public function testCreatesDefaultBoundary():void{ + $this::assertMatchesRegularExpression('/^[a-f\d]{40}$/', $this->multipartStreamBuilder->getBoundary()); + } + + public function testSetBoundary():void{ + $boundary = "0-9a-zA-Z'()+_,-./:=?"; + $this->multipartStreamBuilder->setBoundary($boundary); + + $this::assertSame($boundary, $this->multipartStreamBuilder->getBoundary()); + } + + public function testSetBoundaryEmptyException():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The given boundary is empty'); + + $this->multipartStreamBuilder->setBoundary(''); + } + + public function testSetBoundaryInvalidCharException():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The given boundary contains illegal characters'); + + $this->multipartStreamBuilder->setBoundary('foo#'); + } + + public function testReset():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addString('content a', 'a') + ; + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: form-data; name=\"a\"\r\n". + "Content-Length: 9\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "content a\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + + $this->multipartStreamBuilder->reset(); + + $boundary = $this->multipartStreamBuilder->getBoundary(); + + // phpcs:ignore + $this::assertSame("--$boundary--\r\n", $this->multipartStreamBuilder->build()->getContents()); + } + + public function testCanCreateEmptyBody():void{ + $this::assertMatchesRegularExpression("/--[a-f\d]{40}--\r\n/", $this->multipartStreamBuilder->build()->getContents()); + } + + public function testAddFields():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addString('content a', 'a') + ->addString('content b', 'b') + ; + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: form-data; name=\"a\"\r\n". + "Content-Length: 9\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "content a\r\n". + "--boundary\r\n". + "Content-Disposition: form-data; name=\"b\"\r\n". + "Content-Length: 9\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "content b\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + } + + public function testAddStreams():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addStream($this->streamFactory->createStream('filestream a'), 'a', '/dir/a.txt') + ->addStream($this->streamFactory->createStream('filestream b'), 'b', '/foo/b.jpg') + ; + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: form-data; name=\"a\"; filename=\"a.txt\"\r\n". + "Content-Length: 12\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "filestream a\r\n". + "--boundary\r\n". + "Content-Disposition: form-data; name=\"b\"; filename=\"b.jpg\"\r\n". + "Content-Length: 12\r\n". + "Content-Type: image/jpeg\r\n". + "\r\n". + "filestream b\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + } + + public function testAddFieldWithSameName():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addString('aaa', 'samename', 'a.txt') + ->addString('bbb', 'samename', 'b.jpg') + ; + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: form-data; name=\"samename\"; filename=\"a.txt\"\r\n". + "Content-Length: 3\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "aaa\r\n". + "--boundary\r\n". + "Content-Disposition: form-data; name=\"samename\"; filename=\"b.jpg\"\r\n". + "Content-Length: 3\r\n". + "Content-Type: image/jpeg\r\n". + "\r\n". + "bbb\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + } + + public function testGivenFieldnameCannotBeEmptyException():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid form field name'); + + $this->multipartStreamBuilder->addString('content', ''); + } + + public function testCustomHeaders():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addStream($this->streamFactory->createStream('filestream a'), 'a', '/dir/a.txt', [ + 'x-foo' => 'bar', + 'content-disposition' => 'custom', + ]) + ; + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: custom\r\n". + "Content-Length: 12\r\n". + "Content-Type: text/plain\r\n". + "X-Foo: bar\r\n". + "\r\n". + "filestream a\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + } + + public function testCustomHeadersAndMultipleValues():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addStream($this->streamFactory->createStream('filestream a'), 'a', '/dir/a.txt', [ + 'x-foo' => 'bar', + 'content-disposition' => 'custom', + ]) + // phpcs:ignore + ->addStream($this->streamFactory->createStream('filestream b'), 'b', '/dir/b.jpg', [ + 'cOntenT-Type' => 'custom', + ]) + ; + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: custom\r\n". + "Content-Length: 12\r\n". + "Content-Type: text/plain\r\n". + "X-Foo: bar\r\n". + "\r\n". + "filestream a\r\n". + "--boundary\r\n". + "Content-Disposition: form-data; name=\"b\"; filename=\"b.jpg\"\r\n". + "Content-Length: 12\r\n". + "Content-Type: custom\r\n". + "\r\n". + "filestream b\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + } + + public function testSuppressContentTypeHeader():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addString(content: 'content a', fieldname: 'a', headers: ['Content-Type' => '']) + ; + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: form-data; name=\"a\"\r\n". + "Content-Length: 9\r\n\r\nc". + "ontent a\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + + } + + public function testIgnoresNonContentNonCustomHeaders():void{ + + $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addString(content: 'content a', fieldname: 'a', headers: [ + 'content-whatever' => 'yay', + 'nope' => 'nah', + 'x-what' => 'omg', + 'this' => 'absolutely not', + ]); + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: form-data; name=\"a\"\r\n". + "Content-Length: 9\r\n". + "Content-Type: text/plain\r\n". + "Content-Whatever: yay\r\n". + "X-What: omg\r\n". + "\r\n". + "content a\r\n". + "--boundary--\r\n", + (string)$this->multipartStreamBuilder + ); + + } + + public function testNesting():void{ + + $mp1 = (clone $this->multipartStreamBuilder) + ->setBoundary('boundary-a') + ->addString('content a1', 'a1', 'a1.txt') + ->addString('content a2', 'a2', 'a2.jpg') + ; + + $mp2 = (clone $this->multipartStreamBuilder) + ->setBoundary('boundary-b') + ->addString('content b1', 'b1', 'b1.txt') + ->addStream(stream: $mp1->build(), headers: ['Content-Type' => 'multipart/form-data; boundary="boundary-a"']) + ->addString('content b2', 'b2', 'b2.jpg') + ; + + $this::assertSame( + "--boundary-b\r\n". + "Content-Disposition: form-data; name=\"b1\"; filename=\"b1.txt\"\r\n". + "Content-Length: 10\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "content b1\r\n". + "--boundary-b\r\n". + "Content-Length: 288\r\n". + "Content-Type: multipart/form-data; boundary=\"boundary-a\"\r\n". + "\r\n". + "--boundary-a\r\n". + "Content-Disposition: form-data; name=\"a1\"; filename=\"a1.txt\"\r\n". + "Content-Length: 10\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "content a1\r\n". + "--boundary-a\r\n". + "Content-Disposition: form-data; name=\"a2\"; filename=\"a2.jpg\"\r\n". + "Content-Length: 10\r\n". + "Content-Type: image/jpeg\r\n". + "\r\n". + "content a2\r\n". + "--boundary-a--\r\n". + "\r\n". // does this extra newline bother anyone or can we just ignore it?? + "--boundary-b\r\n". + "Content-Disposition: form-data; name=\"b2\"; filename=\"b2.jpg\"\r\n". + "Content-Length: 10\r\n". + "Content-Type: image/jpeg\r\n". + "\r\n". + "content b2\r\n". + "--boundary-b--\r\n", + (string)$mp2 + ); + + } + + public function testBuildWithMessageInterface():void{ + + $request = $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addStream($this->streamFactory->createStream('filestream a'), 'a', '/foo/a.jpg') + ->build($this->requestFactory->createRequest('POST', 'http://example.com/api/media')) + ; + + $this::assertInstanceOf(MessageInterface::class, $request); + $this::assertTrue($request->hasHeader('content-type')); + $this::assertSame('multipart/form-data; boundary="boundary"', $request->getHeaderLine('content-type')); + + $this::assertSame( + "--boundary\r\n". + "Content-Disposition: form-data; name=\"a\"; filename=\"a.jpg\"\r\n". + "Content-Length: 12\r\n". + "Content-Type: image/jpeg\r\n". + "\r\n". + "filestream a\r\n". + "--boundary--\r\n", + (string)$request->getBody() + ); + + } + + public function testOverwritesContentTypeHeaderInMessage():void{ + + $originalRequest = $this->requestFactory + ->createRequest('POST', 'http://example.com/api/media') + ->withHeader('Content-Type', 'whatever') + ; + + $this::assertTrue($originalRequest->hasHeader('content-type')); + $this::assertSame('whatever', $originalRequest->getHeaderLine('content-type')); + + + $modifiedRequest = $this->multipartStreamBuilder + ->setBoundary('boundary') + ->addStream($this->streamFactory->createStream('filestream a'), 'a', '/foo/a.jpg') + ->build($originalRequest) + ; + + $this::assertTrue($modifiedRequest->hasHeader('content-type')); + $this::assertSame('multipart/form-data; boundary="boundary"', $modifiedRequest->getHeaderLine('content-type')); + } + +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php new file mode 100644 index 0000000..85cd127 --- /dev/null +++ b/tests/RequestTest.php @@ -0,0 +1,131 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\{Request, Uri}; +use Fig\Http\Message\RequestMethodInterface; +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +/** + * + */ +class RequestTest extends TestCase{ + + public function testRequestUriMayBeString():void{ + $this::assertSame('/', (string)(new Request(RequestMethodInterface::METHOD_GET, '/'))->getUri()); + } + + public function testRequestUriMayBeUri():void{ + $uri = new Uri('/'); + + $this::assertSame($uri, (new Request('GET', $uri))->getUri()); + } + + public function testValidateRequestUri():void{ + $this->expectException(InvalidArgumentException::class); + + new Request('GET', '///'); + } + + public function testCapitalizesMethod():void{ + $this::assertSame('GET', (new Request('get', '/'))->getMethod()); + } + + public function testCapitalizesWithMethod():void{ + $this::assertSame('PUT', (new Request('GET', '/'))->withMethod('put')->getMethod()); + } + + public function testWithUri():void{ + $request = new Request('GET', '/'); + $uri1 = $request->getUri(); + $uri2 = new Uri('https://www.example.com'); + + $this::assertSame($uri1, $request->getUri()); + + $request->withUri($uri2); + + $this::assertSame($uri2, $request->getUri()); + } + + public function testWithRequestTarget():void{ + $request = new Request('GET', '/'); + + $this::assertSame('/', $request->getRequestTarget()); + + $request->withRequestTarget('*'); + + $this::assertSame('*', $request->getRequestTarget()); + } + + public function testRequestTargetDoesNotAllowSpaces():void{ + $this->expectException(InvalidArgumentException::class); + + (new Request('GET', '/'))->withRequestTarget('/foo bar'); + } + + public function testRequestTargetDefaultsToSlash():void{ + $request = new Request('GET', ''); + $this::assertSame('/', $request->getRequestTarget()); + + $request = new Request('GET', '*'); + $this::assertSame('*', $request->getRequestTarget()); + + $request = new Request('GET', 'https://foo.com/bar baz/'); + $this::assertSame('/bar%20baz/', $request->getRequestTarget()); + } + + public function testBuildsRequestTarget():void{ + $this::assertSame('/baz?bar=bam', (new Request('GET', 'https://foo.com/baz?bar=bam'))->getRequestTarget()); + } + + public function testBuildsRequestTargetWithFalseyQuery():void{ + $this::assertSame('/baz?0', (new Request('GET', 'https://foo.com/baz?0'))->getRequestTarget()); + } + + public function testCanGetHeaderAsCsv():void{ + $request = (new Request('GET', 'https://foo.com/baz?bar=bam'))->withHeader('Foo', ['a', 'b', 'c']); + + $this::assertSame('a, b, c', $request->getHeaderLine('Foo')); + $this::assertSame('', $request->getHeaderLine('Bar')); + } + + public function testOverridesHostWithUri():void{ + $request = new Request('GET', 'https://foo.com/baz?bar=bam'); + $this::assertSame(['Host' => ['foo.com']], $request->getHeaders()); + + $request->withUri(new Uri('https://www.baz.com/bar')); + $this::assertSame('www.baz.com', $request->getHeaderLine('Host')); + } + + public function testAddsPortToHeader():void{ + $this::assertSame('foo.com:8124', (new Request('GET', 'https://foo.com:8124/bar'))->getHeaderLine('host')); + } + + public function testAddsPortToHeaderAndReplacePreviousPort():void{ + $request = (new Request('GET', 'https://foo.com:8124/bar')) + ->withUri(new Uri('https://foo.com:8125/bar')); + + $this::assertSame('foo.com:8125', $request->getHeaderLine('host')); + } + + public function testWithMethodEmptyMethod():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('HTTP method must not be empty'); + + (new Request('GET', '/foo'))->withMethod(''); + } + +} diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php new file mode 100644 index 0000000..b8a2c04 --- /dev/null +++ b/tests/ResponseTest.php @@ -0,0 +1,80 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\HTTPFactory; +use chillerlan\HTTP\Psr7\Response; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamInterface; + +/** + * + */ +class ResponseTest extends TestCase{ + + public function testDefaultConstructor():void{ + $response = new Response; + + $this::assertSame(200, $response->getStatusCode()); + $this::assertSame('1.1', $response->getProtocolVersion()); + $this::assertSame('OK', $response->getReasonPhrase()); + $this::assertSame([], $response->getHeaders()); + $this::assertInstanceOf(StreamInterface::class, $response->getBody()); + $this::assertSame('', (string)$response->getBody()); + } + + public function testCanConstructWithStatusCode():void{ + $response = new Response(404); + + $this::assertSame(404, $response->getStatusCode()); + $this::assertSame('Not Found', $response->getReasonPhrase()); + } + + public function testCanConstructWithReason():void{ + $response = new Response(200, 'bar'); + $this::assertSame('bar', $response->getReasonPhrase()); + + $response = new Response(200, '0'); + $this::assertSame('0', $response->getReasonPhrase(), 'Falsey reason works'); + } + + public function testWithStatusCodeAndNoReason():void{ + $response = (new Response)->withStatus(201); + $this::assertSame(201, $response->getStatusCode()); + $this::assertSame('Created', $response->getReasonPhrase()); + } + + public function testWithStatusCodeAndReason():void{ + $response = (new Response)->withStatus(201, 'Foo'); + $this::assertSame(201, $response->getStatusCode()); + $this::assertSame('Foo', $response->getReasonPhrase()); + + $response = (new Response)->withStatus(201, '0'); + $this::assertSame(201, $response->getStatusCode()); + $this::assertSame('0', $response->getReasonPhrase(), 'Falsey reason works'); + } + + public function testWithProtocolVersion():void{ + $response = (new Response)->withProtocolVersion('1000'); + $this::assertSame('1000', $response->getProtocolVersion()); + } + + public function testWithBody():void{ + $response = (new Response)->withBody(HTTPFactory::createStreamFromString('0')); + $this::assertInstanceOf(StreamInterface::class, $response->getBody()); + $this::assertSame('0', (string) $response->getBody()); + } + +} diff --git a/tests/ServerRequestTest.php b/tests/ServerRequestTest.php new file mode 100644 index 0000000..af30142 --- /dev/null +++ b/tests/ServerRequestTest.php @@ -0,0 +1,123 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\{ServerRequest, UploadedFile}; +use Fig\Http\Message\RequestMethodInterface; +use PHPUnit\Framework\TestCase; +use InvalidArgumentException; +use const UPLOAD_ERR_OK; + +/** + * + */ +class ServerRequestTest extends TestCase{ + + public function testServerParams():void{ + $params = ['name' => 'value']; + + $request = new ServerRequest(RequestMethodInterface::METHOD_GET, '/', $params); + $this::assertSame($params, $request->getServerParams()); + } + + public function testCookieParams():void{ + $request = new ServerRequest('GET', '/'); + + $this::assertEmpty($request->getCookieParams()); + + $params = ['name' => 'value']; + + $request->withCookieParams($params); + + $this::assertSame($params, $request->getCookieParams()); + } + + public function testQueryParams():void{ + $request = new ServerRequest('GET', '/'); + + $this::assertEmpty($request->getQueryParams()); + + $params = ['name' => 'value']; + + $request->withQueryParams($params); + + $this::assertSame($params, $request->getQueryParams()); + } + + public function testParsedBody():void{ + $request = new ServerRequest('GET', '/'); + + $this::assertEmpty($request->getParsedBody()); + + $params = ['name' => 'value']; + + $request->withParsedBody($params); + + $this::assertSame($params, $request->getParsedBody()); + } + + public function testParsedBodyInvalidArg():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('parsed body value must be an array, object or null'); + /** @phan-suppress-next-line PhanTypeMismatchArgumentProbablyReal */ + (new ServerRequest('GET', '/'))->withParsedBody(''); + } + + public function testAttributes():void{ + $request = new ServerRequest('GET', '/'); + + $this::assertSame([], $request->getAttributes()); + $this::assertNull($request->getAttribute('name')); + $this::assertSame('something', $request->getAttribute('name', 'something'), 'Should return the default value'); + + $request->withAttribute('name', 'value'); + + $this::assertSame('value', $request->getAttribute('name')); + $this::assertSame(['name' => 'value'], $request->getAttributes()); + + $request->withAttribute('other', 'otherValue'); + + $this::assertSame(['name' => 'value', 'other' => 'otherValue'], $request->getAttributes()); + + $request->withoutAttribute('other'); + + $this::assertSame(['name' => 'value'], $request->getAttributes()); + } + + public function testNullAttribute():void{ + $request = (new ServerRequest('GET', '/'))->withAttribute('name', null); + + $this::assertSame(['name' => null], $request->getAttributes()); + $this::assertNull($request->getAttribute('name', 'different-default')); + + $request->withoutAttribute('name'); + + $this::assertSame([], $request->getAttributes()); + $this::assertSame('different-default', $request->getAttribute('name', 'different-default')); + } + + public function testUploadedFiles():void{ + $request = new ServerRequest('GET', '/'); + + $this::assertSame([], $request->getUploadedFiles()); + + $files = ['file' => new UploadedFile('test', 123, UPLOAD_ERR_OK)]; + + $request->withUploadedFiles($files); + + $this::assertSame($files, $request->getUploadedFiles()); + } + +} diff --git a/tests/StreamTest.php b/tests/StreamTest.php new file mode 100644 index 0000000..b9d7f98 --- /dev/null +++ b/tests/StreamTest.php @@ -0,0 +1,188 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\Stream; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\StreamInterface; +use Exception, InvalidArgumentException, RuntimeException; +use function filesize, fopen, fwrite; + +/** + * + */ +class StreamTest extends TestCase{ + use FactoryTrait; + + protected function setUp():void{ + $this->initFactories(); + } + + public function testConstructorThrowsExceptionOnInvalidArgument():void{ + $this->expectException(InvalidArgumentException::class); + + /** + * @noinspection PhpParamsInspection + * @phan-suppress-next-next-line PhanTypeMismatchArgumentProbablyReal + */ + new Stream(true); + } + + public function testConstructorInitializesProperties():void{ + $stream = $this->streamFactory->createStream('data'); + + $this::assertTrue($stream->isReadable()); + $this::assertTrue($stream->isWritable()); + $this::assertTrue($stream->isSeekable()); + $this::assertSame('php://temp', $stream->getMetadata('uri')); + $this::assertIsArray($stream->getMetadata()); + $this::assertSame(4, $stream->getSize()); + $this::assertFalse($stream->eof()); + $stream->close(); + } + + public function testStreamClosesHandleOnDestruct():void{ + $handle = fopen('php://temp', 'r'); + $stream = new Stream($handle); + unset($stream); + $this::assertFalse(is_resource($handle)); + } + + public function testConvertsToString():void{ + $stream = $this->streamFactory->createStream('data'); + $this::assertSame('data', (string)$stream); + $this::assertSame('data', (string)$stream); + $stream->close(); + } + + public function testGetsContents():void{ + $stream = $this->streamFactory->createStream('data'); + $this::assertSame('', $stream->getContents()); + $stream->seek(0); + $this::assertSame('data', $stream->getContents()); + $this::assertSame('', $stream->getContents()); + } + + public function testChecksEof():void{ + $stream = $this->streamFactory->createStream('data'); + $this::assertFalse($stream->eof()); + $stream->read(4); + $this::assertTrue($stream->eof()); + $stream->close(); + } + + public function testGetSize():void{ + $size = filesize(__FILE__); + $handle = fopen(__FILE__, 'r'); + $stream = new Stream($handle); + $this::assertSame($size, $stream->getSize()); + // Load from cache + $this::assertSame($size, $stream->getSize()); + $stream->close(); + } + + public function testEnsuresSizeIsConsistent():void{ + $h = fopen('php://temp', 'w+'); + $this::assertSame(3, fwrite($h, 'foo')); + $stream = new Stream($h); + $this::assertSame(3, $stream->getSize()); + $this::assertSame(4, $stream->write('test')); + $this::assertSame(7, $stream->getSize()); + $this::assertSame(7, $stream->getSize()); + $stream->close(); + } + + public function testProvidesStreamPosition():void{ + $handle = fopen('php://temp', 'w+'); + $stream = new Stream($handle); + $this::assertSame(0, $stream->tell()); + $stream->write('foo'); + $this::assertSame(3, $stream->tell()); + $stream->seek(1); + $this::assertSame(1, $stream->tell()); + $this::assertSame(ftell($handle), $stream->tell()); + $stream->close(); + } + + public function testCanDetachStream():void{ + $handle = fopen('php://temp', 'w+'); + $stream = new Stream($handle); + $stream->write('foo'); + + $this::assertTrue($stream->isReadable()); + $this::assertSame($handle, $stream->detach()); + + $stream->detach(); + + $this::assertFalse($stream->isReadable()); + $this::assertFalse($stream->isWritable()); + $this::assertFalse($stream->isSeekable()); + + $throws = function(callable $fn) use ($stream){ + try{ + $fn($stream); + $this::fail(); + } + catch(Exception){} + }; + + $throws(function(StreamInterface $stream){$stream->read(10);}); + $throws(function(StreamInterface $stream){$stream->write('bar');}); + $throws(function(StreamInterface $stream){$stream->seek(10);}); + $throws(function(StreamInterface $stream){$stream->tell();}); + $throws(function(StreamInterface $stream){$stream->eof();}); + $throws(function(StreamInterface $stream){$stream->getSize();}); + $throws(function(StreamInterface $stream){$stream->getContents();}); + + $this::assertSame('', (string)$stream); + $stream->close(); + } + + public function testCloseClearProperties():void{ + $stream = $this->streamFactory->createStream(); + $stream->close(); + + $this::assertFalse($stream->isSeekable()); + $this::assertFalse($stream->isReadable()); + $this::assertFalse($stream->isWritable()); + $this::assertNull($stream->getSize()); + $this::assertEmpty($stream->getMetadata()); + } + + public function testStreamReadingWithZeroLength():void{ + $stream = $this->streamFactory->createStream(); + + $this::assertSame('', $stream->read(0)); + + $stream->close(); + } + + public function testStreamReadingWithNegativeLength():void{ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Length parameter cannot be negative'); + + $stream = $this->streamFactory->createStream(); + $stream->read(-1); + } + + public function testStreamSeekInvalidPosition():void{ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unable to seek to stream position -1 with whence 0'); + + $stream = $this->streamFactory->createStream(); + $stream->seek(-1); + } + +} diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php new file mode 100644 index 0000000..b233d01 --- /dev/null +++ b/tests/UploadedFileTest.php @@ -0,0 +1,219 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\UploadedFile; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use InvalidArgumentException, RuntimeException; +use function basename, file_exists, fopen, is_scalar, sys_get_temp_dir, tempnam, uniqid, unlink; +use const PHP_OS_FAMILY, UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION, UPLOAD_ERR_FORM_SIZE, + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_NO_FILE, UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_OK, UPLOAD_ERR_PARTIAL; + +/** + * + */ +class UploadedFileTest extends TestCase{ + use FactoryTrait; + + protected array $cleanup; + + // called from FactoryTrait + protected function setUp():void{ + $this->initFactories(); + + $this->cleanup = []; + } + + protected function tearDown():void{ + foreach($this->cleanup as $file){ + if(is_scalar($file) && file_exists($file)){ + unlink($file); + } + } + } + + public static function invalidStreams():array{ + return [ +# 'null' => [null], +# 'true' => [true], +# 'false' => [false], +# 'int' => [1], +# 'float' => [1.1], + 'array' => [['filename']], + 'object' => [(object)['filename']], + ]; + } + + #[DataProvider('invalidStreams')] + public function testRaisesExceptionOnInvalidStreamOrFile(mixed $streamOrFile){ + $this->expectException(InvalidArgumentException::class); + + new UploadedFile($streamOrFile, 0); + } + + public static function invalidErrorStatuses():array{ + return [ + 'negative' => [-1], + 'too-big' => [9], + ]; + } + + #[DataProvider('invalidErrorStatuses')] + public function testRaisesExceptionOnInvalidErrorStatus(int $status):void{ + $this->expectException(InvalidArgumentException::class); + + new UploadedFile(fopen('php://temp', 'wb+'), 0, $status); + } + + public function testGetStreamReturnsOriginalStreamObject():void{ + $stream = $this->streamFactory->createStream(); + $upload = new UploadedFile($stream, 0); + + $this::assertSame($stream, $upload->getStream()); + } + + public function testGetStreamReturnsWrappedPhpStream():void{ + $stream = fopen('php://temp', 'wb+'); + $upload = new UploadedFile($stream, 0); + $uploadStream = $upload->getStream()->detach(); + + $this::assertSame($stream, $uploadStream); + } + + public function testSuccessful():void{ + $stream = $this->streamFactory->createStream('Foo bar!'); + $upload = new UploadedFile($stream, $stream->getSize(), UPLOAD_ERR_OK, 'filename.txt', 'text/plain'); + + $this::assertSame($stream->getSize(), $upload->getSize()); + $this::assertSame('filename.txt', $upload->getClientFilename()); + $this::assertSame('text/plain', $upload->getClientMediaType()); + + $to = tempnam(sys_get_temp_dir(), 'successful'); + $this->cleanup[] = $to; + $upload->moveTo($to); + $this::assertFileExists($to); + $this::assertSame($stream->__toString(), file_get_contents($to)); + } + + public function testMoveCannotBeCalledMoreThanOnce():void{ + $stream = $this->streamFactory->createStream('Foo bar!'); + $upload = new UploadedFile($stream, 0); + + $to = tempnam(sys_get_temp_dir(), 'diac'); + $this->cleanup[] = $to; + $upload->moveTo($to); + $this::assertTrue(file_exists($to)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot retrieve stream after it has already been moved'); + $upload->moveTo($to); + } + + public function testCannotRetrieveStreamAfterMove():void{ + $stream = $this->streamFactory->createStream('Foo bar!'); + $upload = new UploadedFile($stream, 0); + + $to = tempnam(sys_get_temp_dir(), 'diac'); + $this->cleanup[] = $to; + $upload->moveTo($to); + $this::assertFileExists($to); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot retrieve stream after it has already been moved'); + $upload->getStream(); + } + + public function testCannotMoveToEmptyTarget():void{ + $stream = $this->streamFactory->createStream('Foo bar!'); + $upload = new UploadedFile($stream, 0); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid path provided for move operation; must be a non-empty string'); + $upload->moveTo(''); + } + + public function testCannotMoveToUnwritableDirectory():void{ + + if(PHP_OS_FAMILY !== 'Linux'){ + $this->markTestSkipped('testing Linux only'); + } + + $stream = $this->streamFactory->createStream('Foo bar!'); + $upload = new UploadedFile($stream, 0); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Directory is not writable'); + $upload->moveTo('/boot'); + } + + public static function nonOkErrorStatus():array{ + return [ + 'UPLOAD_ERR_INI_SIZE' => [UPLOAD_ERR_INI_SIZE], + 'UPLOAD_ERR_FORM_SIZE' => [UPLOAD_ERR_FORM_SIZE], + 'UPLOAD_ERR_PARTIAL' => [UPLOAD_ERR_PARTIAL], + 'UPLOAD_ERR_NO_FILE' => [UPLOAD_ERR_NO_FILE], + 'UPLOAD_ERR_NO_TMP_DIR' => [UPLOAD_ERR_NO_TMP_DIR], + 'UPLOAD_ERR_CANT_WRITE' => [UPLOAD_ERR_CANT_WRITE], + 'UPLOAD_ERR_EXTENSION' => [UPLOAD_ERR_EXTENSION], + ]; + } + + #[DataProvider('nonOkErrorStatus')] + public function testConstructorDoesNotRaiseExceptionForInvalidStreamWhenErrorStatusPresent(int $status):void{ + $uploadedFile = new UploadedFile('not ok', 0, $status); + $this::assertSame($status, $uploadedFile->getError()); + } + + #[DataProvider('nonOkErrorStatus')] + public function testMoveToRaisesExceptionWhenErrorStatusPresent(int $status):void{ + $uploadedFile = new UploadedFile('not ok', 0, $status); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot retrieve stream due to upload error'); + $uploadedFile->moveTo(__DIR__.'/'.uniqid()); + } + + #[DataProvider('nonOkErrorStatus')] + public function testGetStreamRaisesExceptionWhenErrorStatusPresent(int $status):void{ + $uploadedFile = new UploadedFile('not ok', 0, $status); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot retrieve stream due to upload error'); + $uploadedFile->getStream(); + } + + public function testMoveToCreatesStreamIfOnlyAFilenameWasProvided():void{ + $from = tempnam(sys_get_temp_dir(), 'copy_from'); + $to = tempnam(sys_get_temp_dir(), 'copy_to'); + + $this->cleanup[] = $from; + $this->cleanup[] = $to; + + copy(__FILE__, $from); + + $uploadedFile = new UploadedFile($from, 100, UPLOAD_ERR_OK, basename($from), 'text/plain'); + // why does this produce an error under windows when running with coverage??? + $uploadedFile->moveTo($to); + + $this::assertFileEquals(__FILE__, $to); + } + + public function testNormalizeFilesRaisesException():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid value in files specification'); + + $this->server->normalizeFiles(['test' => 'something']); + } + +} diff --git a/tests/UriTest.php b/tests/UriTest.php new file mode 100644 index 0000000..e97d972 --- /dev/null +++ b/tests/UriTest.php @@ -0,0 +1,600 @@ + + * @copyright 2018 smiley + * @license MIT + */ + +declare(strict_types=1); + +namespace chillerlan\HTTPTest\Psr7; + +use chillerlan\HTTP\Psr7\Uri; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\UriInterface; +use InvalidArgumentException; + +/** + * @see https://github.com/guzzle/psr7/blob/45b30f99ac27b5ca93cb4831afe16285f57b8221/tests/UriTest.php + * @see https://github.com/Nyholm/psr7/blob/fd12ffc87a1e4014b2a7485b88add81c96d105e8/tests/UriTest.php + */ +class UriTest extends TestCase{ + + public function testDefaultReturnValuesOfGetters():void{ + $uri = new Uri; + + $this::assertSame('', $uri->getScheme()); + $this::assertSame('', $uri->getAuthority()); + $this::assertSame('', $uri->getUserInfo()); + $this::assertSame('', $uri->getHost()); + $this::assertNull($uri->getPort()); + $this::assertSame('', $uri->getPath()); + $this::assertSame('', $uri->getQuery()); + $this::assertSame('', $uri->getFragment()); + } + + public function testParsesProvidedUri():void{ + $uri = new Uri('https://user:pass@example.com:8080/path/123?q=abc#test'); + + $this::assertSame('https', $uri->getScheme()); + $this::assertSame('user:pass@example.com:8080', $uri->getAuthority()); + $this::assertSame('user:pass', $uri->getUserInfo()); + $this::assertSame('example.com', $uri->getHost()); + $this::assertSame(8080, $uri->getPort()); + $this::assertSame('/path/123', $uri->getPath()); + $this::assertSame('q=abc', $uri->getQuery()); + $this::assertSame('test', $uri->getFragment()); + $this::assertSame('https://user:pass@example.com:8080/path/123?q=abc#test', (string)$uri); + } + + public function testCanTransformAndRetrievePartsIndividually():void{ + + $uri = (new Uri) + ->withScheme('https') + ->withUserInfo('user', 'pass') + ->withHost('example.com') + ->withPort(8080) + ->withPath('/path/123') + ->withQuery('q=abc') + ->withFragment('test') + ; + + $this::assertSame('https', $uri->getScheme()); + $this::assertSame('user:pass@example.com:8080', $uri->getAuthority()); + $this::assertSame('user:pass', $uri->getUserInfo()); + $this::assertSame('example.com', $uri->getHost()); + $this::assertSame(8080, $uri->getPort()); + $this::assertSame('/path/123', $uri->getPath()); + $this::assertSame('q=abc', $uri->getQuery()); + $this::assertSame('test', $uri->getFragment()); + $this::assertSame('https://user:pass@example.com:8080/path/123?q=abc#test', (string)$uri); + } + + public function testSupportsUrlEncodedValues():void{ + + $uri = (new Uri) + ->withScheme('https') + ->withUserInfo('foo\user%3D=', 'pass%3D=') + ->withHost('example.com') + ->withPort(8080) + ->withPath('/path/123') + ->withQuery('q=abc') + ->withFragment('test') + ; + + $this::assertSame('https', $uri->getScheme()); + $this::assertSame('foo\user%3D%3D:pass%3D%3D@example.com:8080', $uri->getAuthority()); + $this::assertSame('foo\user%3D%3D:pass%3D%3D', $uri->getUserInfo()); + $this::assertSame('example.com', $uri->getHost()); + $this::assertSame(8080, $uri->getPort()); + $this::assertSame('/path/123', $uri->getPath()); + $this::assertSame('q=abc', $uri->getQuery()); + $this::assertSame('test', $uri->getFragment()); + $this::assertSame('https://foo\user%3D%3D:pass%3D%3D@example.com:8080/path/123?q=abc#test', (string)$uri); + } + + public static function getValidUris():array{ + return [ + ['urn:path-rootless'], + ['urn:path:with:colon'], + ['urn:/path-absolute'], + ['urn:/'], + // only scheme with empty path + ['urn:'], + // only path + ['/'], + ['relative/'], + ['0'], + // same document reference + [''], + // network path without scheme + ['//example.org'], + ['//example.org/'], + ['//example.org?q#h'], + // only query + ['?q'], + ['?q=abc&foo=bar'], + // only fragment + ['#fragment'], + // dot segments are not removed automatically + ['./foo/../bar'], + ]; + } + + #[DataProvider('getValidUris')] + public function testValidUrisStayValid(string $input):void{ + $this::assertSame($input, (string)(new Uri($input))); + } + + #[DataProvider('getValidUris')] + public function testFromParts(string $input):void{ + $this::assertSame($input, (string)(new Uri(parse_url($input)))); + } + + public static function getInvalidUris():array{ + return [ + // parse_url() requires the host component which makes sense for http(s) + // but not when the scheme is not known or different. So '//' or '///' is + // currently invalid as well but should not according to RFC 3986. + 'only scheme' => ['https://'], + // host cannot contain ":" + 'host with colon' => ['urn://host:with:colon'], + ]; + } + + #[DataProvider('getInvalidUris')] + public function testInvalidUrisThrowException(string $invalidUri):void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse URI'); + + new Uri($invalidUri); + } + + public function testPortMustBeValid():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid port: 82517'); + + (new Uri)->withPort(82517); + } + + public function testWithPortCannotBeNegative():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('invalid port: -1'); + + (new Uri)->withPort(-1); + } + + public function testParseUriPortCannotBeNegative():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to parse URI'); + + new Uri('//example.com:-1'); + } + + public function testParseUriPortCanBeZero(){ + // @see https://bugs.php.net/bug.php?id=80266 + $this::assertSame(0, (new Uri('//example.com:0'))->getPort()); + } + + public function testCanParseFalseyUriParts():void{ + $uri = new Uri('0://0:0@0/0?0#0'); + + $this::assertSame('0', $uri->getScheme()); + $this::assertSame('0:0@0', $uri->getAuthority()); + $this::assertSame('0:0', $uri->getUserInfo()); + $this::assertSame('0', $uri->getHost()); + $this::assertSame('/0', $uri->getPath()); + $this::assertSame('0', $uri->getQuery()); + $this::assertSame('0', $uri->getFragment()); + $this::assertSame('0://0:0@0/0?0#0', (string)$uri); + } + + public function testCanConstructFalseyUriParts():void{ + + $uri = (new Uri) + ->withScheme('0') + ->withUserInfo('0', '0') + ->withHost('0') + ->withPath('/0') + ->withQuery('0') + ->withFragment('0') + ; + + $this::assertSame('0', $uri->getScheme()); + $this::assertSame('0:0@0', $uri->getAuthority()); + $this::assertSame('0:0', $uri->getUserInfo()); + $this::assertSame('0', $uri->getHost()); + $this::assertSame('/0', $uri->getPath()); + $this::assertSame('0', $uri->getQuery()); + $this::assertSame('0', $uri->getFragment()); + $this::assertSame('0://0:0@0/0?0#0', (string)$uri); + } + + public function testSchemeIsNormalizedToLowercase():void{ + $uri = new Uri('HTTPS://example.com'); + + $this::assertSame('https', $uri->getScheme()); + $this::assertSame('https://example.com', (string)$uri); + + $uri = (new Uri('//example.com'))->withScheme('HTTPS'); + + $this::assertSame('https', $uri->getScheme()); + $this::assertSame('https://example.com', (string)$uri); + } + + public function testHostIsNormalizedToLowercase():void{ + $uri = new Uri('//eXaMpLe.CoM'); + + $this::assertSame('example.com', $uri->getHost()); + $this::assertSame('//example.com', (string)$uri); + + $uri = (new Uri)->withHost('eXaMpLe.CoM'); + + $this::assertSame('example.com', $uri->getHost()); + $this::assertSame('//example.com', (string)$uri); + } + + public function testPortIsNullIfStandardPortForScheme():void{ + // HTTPS standard port + $uri = new Uri('https://example.com:443'); + + $this::assertNull($uri->getPort()); + $this::assertSame('example.com', $uri->getAuthority()); + + $uri = (new Uri('https://example.com'))->withPort(443); + + $this::assertNull($uri->getPort()); + $this::assertSame('example.com', $uri->getAuthority()); + + // HTTP standard port + $uri = new Uri('https://example.com:443'); + + $this::assertNull($uri->getPort()); + $this::assertSame('example.com', $uri->getAuthority()); + + $uri = (new Uri('http://example.com'))->withPort(80); + + $this::assertNull($uri->getPort()); + $this::assertSame('example.com', $uri->getAuthority()); + } + + public function testPortIsReturnedIfSchemeUnknown():void{ + $uri = (new Uri('//example.com'))->withPort(80); + + $this::assertSame(80, $uri->getPort()); + $this::assertSame('example.com:80', $uri->getAuthority()); + } + + public function testStandardPortIsNullIfSchemeChanges():void{ + $uri = new Uri('http://example.com:443'); + + $this::assertSame('http', $uri->getScheme()); + $this::assertSame(443, $uri->getPort()); + + $uri = $uri->withScheme('https'); + + $this::assertNull($uri->getPort()); + } + + public function testPortCanBeRemoved():void{ + $uri = (new Uri('https://example.com:8080'))->withPort(null); + + $this::assertNull($uri->getPort()); + $this::assertSame('https://example.com', (string)$uri); + } + + /** + * In RFC 8986 the host is optional and the authority can only + * consist of the user info and port. + */ + public function testAuthorityWithUserInfoOrPortButWithoutHost():void{ + $uri = (new Uri)->withUserInfo('user', 'pass'); + + $this::assertSame('user:pass', $uri->getUserInfo()); + $this::assertSame('user:pass@', $uri->getAuthority()); + + $uri = $uri->withPort(8080); + + $this::assertSame(8080, $uri->getPort()); + $this::assertSame('user:pass@:8080', $uri->getAuthority()); + $this::assertSame('//user:pass@:8080', (string)$uri); + + $uri = $uri->withUserInfo(''); + + $this::assertSame(':8080', $uri->getAuthority()); + } + + public function testHostInUriDefaultsToLocalhost():void{ + $uri = (new Uri)->withScheme('https'); + // host is empty when requested specifically + $this::assertSame('', $uri->getHost()); + // "fixed" to localhost + $this::assertSame('localhost', $uri->getAuthority()); + $this::assertSame('https://localhost', (string)$uri); + } + + public function testFileSchemeWithEmptyHostReconstruction():void{ + $uri = new Uri('file:///tmp/filename.ext'); + + $this::assertSame('', $uri->getHost()); + $this::assertSame('', $uri->getAuthority()); + $this::assertSame('file:///tmp/filename.ext', (string)$uri); + } + + public static function uriComponentsEncodingProvider():array{ + $unreserved = 'a-zA-Z0-9.-_~!$&\'()*+,;=:@'; + + return [ + 'Percent encode spaces' => [ + '/pa th?q=va lue#frag ment', + '/pa%20th', + 'q=va%20lue', + 'frag%20ment', + '/pa%20th?q=va%20lue#frag%20ment', + ], + 'Percent encode multibyte' => [ + '/€?€#€', + '/%E2%82%AC', + '%E2%82%AC', + '%E2%82%AC', + '/%E2%82%AC?%E2%82%AC#%E2%82%AC', + ], + 'Don\'t encode already encoded' => [ + '/pa%20th?q=va%20lue#frag%20ment', + '/pa%20th', + 'q=va%20lue', + 'frag%20ment', + '/pa%20th?q=va%20lue#frag%20ment', + ], + 'Percent encode invalid percent encodings' => [ + '/pa%2-th?q=va%2-lue#frag%2-ment', + '/pa%252-th', + 'q=va%252-lue', + 'frag%252-ment', + '/pa%252-th?q=va%252-lue#frag%252-ment', + ], + 'Don\'t encode path segments' => [ + '/pa/th//two?q=va/lue#frag/ment', + '/pa/th//two', + 'q=va/lue', + 'frag/ment', + '/pa/th//two?q=va/lue#frag/ment', + ], + 'Don\'t encode unreserved chars or sub-delimiters' => [ + "/$unreserved?$unreserved#$unreserved", + "/$unreserved", + $unreserved, + $unreserved, + "/$unreserved?$unreserved#$unreserved", + ], + 'Encoded unreserved chars are not decoded' => [ + '/p%61th?q=v%61lue#fr%61gment', + '/p%61th', + 'q=v%61lue', + 'fr%61gment', + '/p%61th?q=v%61lue#fr%61gment', + ], + ]; + } + + #[DataProvider('uriComponentsEncodingProvider')] + public function testUriComponentsGetEncodedProperly( + string $input, + string $path, + string $query, + string $fragment, + string $output + ):void{ + $uri = new Uri($input); + + $this::assertSame($path, $uri->getPath()); + $this::assertSame($query, $uri->getQuery()); + $this::assertSame($fragment, $uri->getFragment()); + $this::assertSame($output, (string)$uri); + } + + public function testWithPathEncodesProperly():void{ + $uri = (new Uri)->withPath('/baz?#€/b%61r'); + // Query and fragment delimiters and multibyte chars are encoded. + $this::assertSame('/baz%3F%23%E2%82%AC/b%61r', $uri->getPath()); + $this::assertSame('/baz%3F%23%E2%82%AC/b%61r', (string)$uri); + } + + public function testWithQueryEncodesProperly():void{ + $uri = (new Uri)->withQuery('?=#&€=/&b%61r'); + // A query starting with a "?" is valid and must not be magically removed. Otherwise, it would be impossible to + // construct such a URI. Also, the "?" and "/" does not need to be encoded in the query. + $this::assertSame('?=%23&%E2%82%AC=/&b%61r', $uri->getQuery()); + $this::assertSame('??=%23&%E2%82%AC=/&b%61r', (string)$uri); + } + + public function testWithFragmentEncodesProperly():void{ + $uri = (new Uri)->withFragment('#€?/b%61r'); + // A fragment starting with a "#" is valid and must not be magically removed. Otherwise, it would be impossible to + // construct such a URI. Also, the "?" and "/" does not need to be encoded in the fragment. + $this::assertSame('%23%E2%82%AC?/b%61r', $uri->getFragment()); + $this::assertSame('#%23%E2%82%AC?/b%61r', (string)$uri); + } + + public function testAllowsForRelativeUri():void{ + $uri = (new Uri)->withPath('foo'); + + $this::assertSame('foo', $uri->getPath()); + $this::assertSame('foo', (string)$uri); + } + + public function testPathStartingWithTwoSlashes():void{ + $uri = new Uri('https://example.org//path-not-host.com'); + + $this::assertSame('//path-not-host.com', $uri->getPath()); + + $uri = $uri->withScheme(''); + + $this::assertSame('//example.org//path-not-host.com', (string)$uri); // This is still valid + + $uri = $uri->withHost(''); + // we're not going to "fix" this case here as the path is requested explicitly - deal with it + $this::assertSame('//path-not-host.com', $uri->getPath()); + // URI "//path-not-host.com" would be interpreted as network reference and thus change the original path to the host + $this::assertSame('/path-not-host.com', (string)$uri); + } + + public function testRelativeUriWithPathBeginningWithColonSegmentIsInvalid():void{ + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A relative URI must not have a path beginning with a segment containing a colon'); + + (string)((new Uri)->withPath('mailto:foo')); + } + + public function testRelativeUriWithPathHavingColonSegment():void{ + $uri = (new Uri('urn:/mailto:foo'))->withScheme(''); + $this::assertSame('/mailto:foo', $uri->getPath()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A relative URI must not have a path beginning with a segment containing a colon'); + + (string)((new Uri('urn:mailto:foo'))->withScheme('')); + } + + public function testAddsSlashForRelativeUriStringWithHost():void{ + // If the path is rootless and an authority is present, the path MUST be prefixed by "/". + $uri = (new Uri)->withPath('foo')->withHost('example.com'); + + $this::assertSame('foo', $uri->getPath()); // path alone is not fixed as per interface spec + // concatenating a relative path with a host doesn't work: "//example.comfoo" would be wrong + $this::assertSame('//example.com/foo', (string)$uri); + } + + public static function hostProvider():array{ + return [ + 'normalized host' => ['MaStEr.eXaMpLe.CoM', 'master.example.com'], + 'simple host' => ['www.example.com', 'www.example.com'], + 'IPv6 Host' => ['[::1]', '[::1]'], + ]; + } + + /** + * The value returned MUST be normalized to lowercase, per RFC 3986 Section 3.2.2. + */ + #[DataProvider('hostProvider')] + public function testGetHost(string $host, string $expected):void{ + $uri = (new Uri)->withHost($host); + + $this::assertInstanceOf(UriInterface::class, $uri); + $this::assertSame($expected, $uri->getHost(), 'Host must be normalized according to RFC3986'); + } + + public static function authorityProvider():array{ + return [ + 'authority' => [ + 'scheme' => 'http', + 'user' => 'User', + 'pass' => 'Pass', + 'host' => 'master.example.com', + 'port' => 443, + 'authority' => 'User:Pass@master.example.com:443', + ], + 'without port' => [ + 'scheme' => 'http', + 'user' => 'User', + 'pass' => 'Pass', + 'host' => 'master.example.com', + 'port' => null, + 'authority' => 'User:Pass@master.example.com', + ], + 'with standard port' => [ + 'scheme' => 'http', + 'user' => 'User', + 'pass' => 'Pass', + 'host' => 'master.example.com', + 'port' => 80, + 'authority' => 'User:Pass@master.example.com', + ], + 'authority without pass' => [ + 'scheme' => 'http', + 'user' => 'User', + 'pass' => '', + 'host' => 'master.example.com', + 'port' => null, + 'authority' => 'User@master.example.com', + ], + 'authority without port and userinfo' => [ + 'scheme' => 'http', + 'user' => '', + 'pass' => '', + 'host' => 'master.example.com', + 'port' => null, + 'authority' => 'master.example.com', + ], + ]; + } + + /** + * If the port component is not set or is the standard port for the current scheme, it SHOULD NOT be included. + */ + #[DataProvider('authorityProvider')] + public function testGetAuthority(string $scheme, string $user, string $pass, string $host, ?int $port, string $authority):void{ + + $uri = (new Uri) + ->withHost($host) + ->withScheme($scheme) + ->withUserInfo($user, $pass) + ->withPort($port) + ; + + $this::assertSame($authority, $uri->getAuthority()); + } + + public function testFilterHostIPv6():void{ + $this::assertSame('[::1]', (new Uri(['host' => '::1']))->getHost()); + $this::assertSame('[::1]', (new Uri(['host' => '[::1]']))->getHost()); + } + + public function testWithPartSamePart():void{ + $expected = 'https://example.com/foo#bar'; + + $uri = new Uri($expected); + $uri->withScheme('https'); + + $this::assertSame($expected, (string)$uri); + + $uri->withHost('example.com'); + + $this::assertSame($expected, (string)$uri); + + $uri->withPath('/foo'); + + $this::assertSame($expected, (string)$uri); + + $uri->withFragment('bar'); + + $this::assertSame($expected, (string)$uri); + } + + public function testInternationalizedDomainName():void{ + $uri = new Uri('https://яндекс.рф'); + + $this::assertSame('яндекс.рф', $uri->getHost()); + + $uri = new Uri('https://яндекAс.рф'); + + $this::assertSame('яндекaс.рф', $uri->getHost()); + } + + public function testIPv6Host():void{ + $uri = new Uri('https://[2a00:f48:1008::212:183:10]'); + + $this::assertSame('[2a00:f48:1008::212:183:10]', $uri->getHost()); + + $uri = new Uri('https://[2a00:f48:1008::212:183:10]:56?foo=bar'); + + $this::assertSame('[2a00:f48:1008::212:183:10]', $uri->getHost()); + $this::assertSame(56, $uri->getPort()); + $this::assertSame('foo=bar', $uri->getQuery()); + } + +}