diff --git a/.gitattributes b/.gitattributes index f235654..323c441 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,14 @@ -tests/ export-ignore -.editorconfig export-ignore -.gitattributes export-ignore -.gitignore export-ignore -.php_cs export-ignore -.scrutinizer.yml export-ignore -.travis.yml export-ignore -.phpstan.neon.dist export-ignore -phpunit.xml.dist export-ignore +.github/ export-ignore +doc/ export-ignore +tests/ export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.php-cs-fixer.dist.php export-ignore +.scrutinizer.yml export-ignore +.travis.yml export-ignore +phpstan.neon.dist export-ignore +phpstan-baseline.neon export-ignore +phpunit.xml.dist export-ignore +psalm.xml export-ignore +psalm.baseline.xml export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..11255a0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [nyholm, Zegnat] diff --git a/.github/workflows/bc.yml b/.github/workflows/bc.yml new file mode 100644 index 0000000..18b35c8 --- /dev/null +++ b/.github/workflows/bc.yml @@ -0,0 +1,15 @@ +on: + pull_request: ~ + push: + branches: + - "master" + +name: Roave +jobs: + roave_bc_check: + name: BC Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Roave BC Check + uses: docker://nyholm/roave-bc-check-ga diff --git a/.github/workflows/branch-alias.yml b/.github/workflows/branch-alias.yml new file mode 100644 index 0000000..377cd29 --- /dev/null +++ b/.github/workflows/branch-alias.yml @@ -0,0 +1,66 @@ +name: Update branch alias + +on: + push: + tags: ['*'] + +jobs: + branch-alias: + name: Update branch alias + runs-on: ubuntu-latest + + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + coverage: none + + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: main + + - name: Find branch alias + id: find_alias + run: | + TAG=$(echo $GITHUB_REF | cut -d'/' -f 3) + echo "Last tag was $TAG" + ARR=(${TAG//./ }) + ARR[1]=$((${ARR[1]}+1)) + echo ::set-output name=alias::${ARR[0]}.${ARR[1]} + + - name: Update branch alias + run: | + CURRENT_ALIAS=$(composer config extra.branch-alias.dev-main | cut -d'-' -f 1) + + # If there is a current value on the branch alias + if [ ! -z $CURRENT_ALIAS ]; then + NEW_ALIAS=${{ steps.find_alias.outputs.alias }} + CURRENT_ARR=(${CURRENT_ALIAS//./ }) + NEW_ARR=(${NEW_ALIAS//./ }) + + if [ ${CURRENT_ARR[0]} -gt ${NEW_ARR[0]} ]; then + echo "The current value for major version is larger" + exit 1; + fi + + if [ ${CURRENT_ARR[0]} -eq ${NEW_ARR[0]} ] && [ ${CURRENT_ARR[1]} -gt ${NEW_ARR[1]} ]; then + echo "The current value for minor version is larger" + exit 1; + fi + fi + + composer config extra.branch-alias.dev-main ${{ steps.find_alias.outputs.alias }}-dev + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v3 + with: + base: main + branch: branch-alias-update + author: GitHub + committer: GitHub + commit-message: Updating branch alias to ${{ steps.find_alias.outputs.alias }} + title: Update branch alias + body: | + Since we just tagged a new version, we need to update composer.json branch alias. diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..f3c88b7 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,70 @@ +on: + pull_request: ~ + push: + branches: + - "master" + +name: Static analysis +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + tools: phpstan:1.8.11, cs2pr + + - name: Download dependencies + uses: ramsey/composer-install@v2 + + - name: PHPStan + run: phpstan analyze --no-progress --error-format=checkstyle | cs2pr + + php-cs-fixer: + name: PHP-CS-Fixer + runs-on: ubuntu-22.04 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + coverage: none + tools: php-cs-fixer:3.15, cs2pr + + - name: Display PHP-CS-Fixer version + run: php-cs-fixer --version + + - name: PHP-CS-Fixer + run: php-cs-fixer fix --dry-run --format=checkstyle | cs2pr + + psalm: + name: Psalm + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + extensions: apcu, redis + coverage: none + tools: vimeo/psalm:5.9 + + - name: Download dependencies + uses: ramsey/composer-install@v2 + + - name: Psalm + run: psalm --no-progress --output-format=github diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..80e93be --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,78 @@ +on: + pull_request: ~ + push: + branches: + - "master" + +name: Tests + +jobs: + phpunit: + name: PHPUnit with PHP ${{ matrix.php-version }} on ${{ matrix.operating-system }} + + strategy: + matrix: + operating-system: + - 'ubuntu-latest' + php-version: + - '7.2' + - '7.3' + - '7.4' + - '8.0' + - '8.1' + - '8.2' + include: + - operating-system: 'windows-latest' + php-version: '7.4' + + runs-on: ${{ matrix.operating-system }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + coverage: none + php-version: ${{ matrix.php-version }} + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('composer.*') }} + restore-keys: | + composer-${{ runner.os }}-${{ matrix.php-version }}- + composer-${{ runner.os }}- + composer- + + - name: Download dependencies + run: composer update --no-interaction --no-progress --optimize-autoloader + + - name: Run PHPUnit + run: ./vendor/bin/phpunit + + phpunit-lowest: + name: PHPUnit lowest dependencies + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install PHP with extensions + uses: shivammathur/setup-php@v2 + with: + coverage: none + php-version: 7.4 + + - name: Download dependencies + run: composer update --no-interaction --no-progress --optimize-autoloader --prefer-stable --prefer-lowest + + - name: Run PHPUnit + run: ./vendor/bin/phpunit --coverage-text --coverage-clover=coverage.xml diff --git a/.gitignore b/.gitignore index ed486bb..d401b18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -/composer.lock -/phpstan.neon -/phpunit.xml -/vendor/ +composer.lock +phpstan.neon +phpunit.xml +vendor +.php-cs-fixer.cache +.phpunit.result.cache +.tmp diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..04765de --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,25 @@ +in(__DIR__.'/src') + ->in(__DIR__.'/tests'); + +$config = new PhpCsFixer\Config(); + +return $config->setRules([ + '@Symfony' => true, + '@Symfony:risky' => true, + 'native_function_invocation' => ['include'=> ['@all']], + 'native_constant_invocation' => true, + 'ordered_imports' => true, + 'declare_strict_types' => false, + 'linebreak_after_opening_tag' => false, + 'single_import_per_statement' => false, + 'blank_line_after_opening_tag' => false, + 'concat_space' => ['spacing'=>'one'], + 'phpdoc_align' => ['align'=>'left'], +]) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/.php_cs b/.php_cs deleted file mode 100644 index 31a2cf2..0000000 --- a/.php_cs +++ /dev/null @@ -1,23 +0,0 @@ -setRiskyAllowed(true) - ->setRules([ - '@Symfony' => true, - '@Symfony:risky' => true, - 'array_syntax' => array('syntax' => 'short'), - 'native_function_invocation' => true, - 'ordered_imports' => true, - 'declare_strict_types' => true, - 'single_import_per_statement' => false, - 'concat_space' => ['spacing'=>'one'], - 'phpdoc_align' => ['align'=>'left'], - ]) - ->setFinder( - PhpCsFixer\Finder::create() - ->in(__DIR__.'/src') - ->name('*.php') - ) -; - -return $config; diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index f7a088e..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,8 +0,0 @@ -filter: - excluded_paths: [vendor/*, Tests/*] -checks: - php: - code_rating: true - duplication: true -tools: - external_code_coverage: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 46e0afa..0000000 --- a/.travis.yml +++ /dev/null @@ -1,51 +0,0 @@ -language: php -sudo: false - -cache: - directories: - - $HOME/.composer/cache - -php: - - 7.1 - - 7.2 - - 7.3 - -env: - global: - - TEST_COMMAND="composer test" - -branches: - except: - - /^patch-.*$/ - -matrix: - fast_finish: true - allow_failures: - - php: nightly - env: COMPOSER_FLAGS="--ignore-platform-reqs" - include: - - php: 7.2 - name: Backward compatibillity check - env: DEPENDENCIES="roave/backward-compatibility-check" TEST_COMMAND="./vendor/bin/roave-backward-compatibility-check" - - php: 7.2 - name: Lowest version of dependencies - env: COMPOSER_FLAGS="--prefer-stable --prefer-lowest" COVERAGE=true TEST_COMMAND="composer test-ci" - - php: 7.2 - name: PHPStan - env: DEPENDENCIES="phpstan/phpstan" TEST_COMMAND="./vendor/bin/phpstan" - - php: nightly - name: PHP 8.0 - env: COMPOSER_FLAGS="--ignore-platform-reqs" - -before_install: - - if ! [ -v "$DEPENDENCIES" ]; then composer require --no-update ${DEPENDENCIES}; fi; - -install: - - composer update ${COMPOSER_FLAGS} --prefer-source --no-interaction - -script: - - $TEST_COMMAND - -after_success: - - if [[ "$COVERAGE" = true ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi - - if [[ "$COVERAGE" = true ]]; then php ocular.phar code-coverage:upload --format=php-clover build/coverage.xml; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index fa09be0..17a819f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,114 @@ All notable changes to this project will be documented in this file, in reverse chronological order by release. +## 1.8.2 + +- Fix deprecation warnings in PHP 8.4 + +## 1.8.1 + +- Fix error handling in Stream::getContents() + +## 1.8.0 + +- Deprecate HttplugFactory, use Psr17Factory instead +- Make depencendy on php-http/message-factory optional + +## 1.7.0 + +- Bump to PHP 7.2 minimum +- Allow psr/http-message v2 +- Use copy-on-write for streams created from strings + +## 1.6.1 + +- Security fix: CVE-2023-29197 + +## 1.6.0 + +### Changed + +- Seek to the begining of the string when using Stream::create() +- Populate ServerRequest::getQueryParams() on instantiation +- Encode [reserved characters](https://www.rfc-editor.org/rfc/rfc3986#appendix-A) in userinfo in Uri +- Normalize leading slashes for Uri::getPath() +- Make Stream's constructor public +- Add some missing type checks on arguments + +## 1.5.1 + +### Fixed + +- Fixed deprecations on PHP 8.1 + +## 1.5.0 + +### Added + +- Add explicit `@return mixed` +- Add explicit return types to HttplugFactory + +### Fixed + +- Improve error handling with streams + +## 1.4.1 + +### Fixed + +- `Psr17Factory::createStreamFromFile`, `UploadedFile::moveTo`, and + `UploadedFile::getStream` no longer throw `ValueError` in PHP 8. + +## 1.4.0 + +### Removed + +The `final` keyword was replaced by `@final` annotation. + +## 1.3.2 + +### Fixed + +- `Stream::read()` must not return boolean. +- Improved exception message when using wrong HTTP status code. + +## 1.3.1 + +### Fixed + +- Allow installation on PHP8 + +## 1.3.0 + +### Added + +- Make Stream::__toString() compatible with throwing exceptions on PHP 7.4. + +### Fixed + +- Support for UTF-8 hostnames +- Support for numeric header names + +## 1.2.1 + +### Changed + +- Added `.github` and `phpstan.neon.dist` to `.gitattributes`. + +## 1.2.0 + +### Changed + +- Change minimal port number to 0 (unix socket) +- Updated `Psr17Factory::createResponse` to respect the specification. If second + argument is not used, a standard reason phrase. If an empty string is passed, + then the reason phrase will be empty. + +### Fixed + +- Check for seekable on the stream resource. +- Fixed the `Response::$reason` should never be null. + ## 1.1.0 ### Added @@ -18,7 +126,7 @@ All notable changes to this project will be documented in this file, in reverse ### Fixed - Handle `fopen` failing in createStreamFromFile according to PSR-7. -- Reduce execution path to speed up performance. +- Reduce execution path to speed up performance. - Fixed typos. - Code style. @@ -27,10 +135,10 @@ All notable changes to this project will be documented in this file, in reverse ### Added - Support for final PSR-17 (HTTP factories). (`Psr17Factory`) -- Support for numeric header values. -- Support for empty header values. +- Support for numeric header values. +- Support for empty header values. - All classes are final -- `HttplugFactory` that implements factory interfaces from HTTPlug. +- `HttplugFactory` that implements factory interfaces from HTTPlug. ### Changed @@ -39,27 +147,26 @@ All notable changes to this project will be documented in this file, in reverse ### Removed - The HTTPlug discovery strategy was removed since it is included in php-http/discovery 1.4. -- `UploadedFileFactory()` was removed in favor for `Psr17Factory`. -- `ServerRequestFactory()` was removed in favor for `Psr17Factory`. -- `StreamFactory`, `UriFactory`, abd `MessageFactory`. Use `HttplugFactory` instead. -- `ServerRequestFactory::createServerRequestFromArray`, `ServerRequestFactory::createServerRequestFromArrays` and - `ServerRequestFactory::createServerRequestFromGlobals`. Please use the new `nyholm/psr7-server` instead. +- `UploadedFileFactory()` was removed in favor for `Psr17Factory`. +- `ServerRequestFactory()` was removed in favor for `Psr17Factory`. +- `StreamFactory`, `UriFactory`, abd `MessageFactory`. Use `HttplugFactory` instead. +- `ServerRequestFactory::createServerRequestFromArray`, `ServerRequestFactory::createServerRequestFromArrays` and + `ServerRequestFactory::createServerRequestFromGlobals`. Please use the new `nyholm/psr7-server` instead. ## 0.3.0 ### Added - Return types. -- Many `InvalidArgumentException`s are thrown when you use invalid arguments. +- Many `InvalidArgumentException`s are thrown when you use invalid arguments. - Integration tests for `UploadedFile` and `ServerRequest`. ### Changed -- We dropped PHP7.0 support. -- PSR-17 factories have been marked as internal. They do not fall under our BC promise until PSR-17 is accepted. -- `UploadedFileFactory::createUploadedFile` does not accept a string file path. +- We dropped PHP7.0 support. +- PSR-17 factories have been marked as internal. They do not fall under our BC promise until PSR-17 is accepted. +- `UploadedFileFactory::createUploadedFile` does not accept a string file path. ## 0.2.3 No changelog before this release - diff --git a/README.md b/README.md index 25237d2..7fc30bc 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,25 @@ # PSR-7 implementation [![Latest Version](https://img.shields.io/github/release/Nyholm/psr7.svg?style=flat-square)](https://github.com/Nyholm/psr7/releases) -[![Build Status](https://img.shields.io/travis/Nyholm/psr7/master.svg?style=flat-square)](https://travis-ci.org/Nyholm/psr7) -[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/Nyholm/psr7.svg?style=flat-square)](https://scrutinizer-ci.com/g/Nyholm/psr7) -[![Quality Score](https://img.shields.io/scrutinizer/g/Nyholm/psr7.svg?style=flat-square)](https://scrutinizer-ci.com/g/Nyholm/psr7) [![Total Downloads](https://poser.pugx.org/nyholm/psr7/downloads)](https://packagist.org/packages/nyholm/psr7) [![Monthly Downloads](https://poser.pugx.org/nyholm/psr7/d/monthly.png)](https://packagist.org/packages/nyholm/psr7) [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) - +[![Static analysis](https://github.com/Nyholm/psr7/actions/workflows/static.yml/badge.svg?branch=master)](https://github.com/Nyholm/psr7/actions/workflows/static.yml?query=branch%3Amaster) +[![Tests](https://github.com/Nyholm/psr7/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/Nyholm/psr7/actions/workflows/tests.yml?query=branch%3Amaster) A super lightweight PSR-7 implementation. Very strict and very fast. -| Description | Guzzle | Zend | Slim | Nyholm | +| Description | Guzzle | Laminas | Slim | Nyholm | | ---- | ------ | ---- | ---- | ------ | -| Lines of code | 3 000 | 3 000 | 1 700 | 1 000 | -| PHP7 | No | Yes | No | Yes | +| Lines of code | 3.300 | 3.100 | 1.900 | 1.000 | | PSR-7* | 66% | 100% | 75% | 100% | | PSR-17 | No | Yes | Yes | Yes | | HTTPlug | No | No | No | Yes | -| Performance** | 1.34x | 1x | 1.16x | 1.75x | +| Performance (runs per second)** | 14.553 | 14.703 | 13.416 | 17.734 | \* Percent of completed tests in https://github.com/php-http/psr7-integration-tests -\** See benchmark at https://github.com/Nyholm/http-client-benchmark (higher is better) +\** Benchmark with 50.000 runs. See https://github.com/devanych/psr-http-benchmark (higher is better) ## Installation @@ -30,27 +27,27 @@ A super lightweight PSR-7 implementation. Very strict and very fast. composer require nyholm/psr7 ``` -If you are using Symfony Flex then you get all message factories registered as services. +If you are using Symfony Flex then you get all message factories registered as services. ## Usage -The PSR-7 objects do not contain any other public methods then those defined in -the [PSR-7 specification](https://www.php-fig.org/psr/psr-7/). +The PSR-7 objects do not contain any other public methods than those defined in +the [PSR-7 specification](https://www.php-fig.org/psr/psr-7/). ### Create objects -Use the PSR-17 factory to create requests, streams, URIs etc. +Use the PSR-17 factory to create requests, streams, URIs etc. ```php -$factory = new \Nyholm\Psr7\Factory\Psr17Factory(); -$request = $factory->createRequest('GET', 'http://tnyholm.se'); -$stream = $factory->createStream('foobar'); +$psr17Factory = new \Nyholm\Psr7\Factory\Psr17Factory(); +$request = $psr17Factory->createRequest('GET', 'http://tnyholm.se'); +$stream = $psr17Factory->createStream('foobar'); ``` ### Sending a request -With [HTTPlug](http://httplug.io/) or any other PSR-18 (HTTP client) you may send -requests like: +With [HTTPlug](http://httplug.io/) or any other PSR-18 (HTTP client) you may send +requests like: ```bash composer require kriswallsmith/buzz @@ -58,15 +55,15 @@ composer require kriswallsmith/buzz ```php $psr17Factory = new \Nyholm\Psr7\Factory\Psr17Factory(); -$psr18Client = new Buzz\Client\Curl($psr17Factory); +$psr18Client = new \Buzz\Client\Curl($psr17Factory); -$request = (new Psr17Factory())->createRequest('GET', 'http://tnyholm.se'); +$request = $psr17Factory->createRequest('GET', 'http://tnyholm.se'); $response = $psr18Client->sendRequest($request); ``` ### Create server requests -The [`nyholm/psr7-server`](https://github.com/Nyholm/psr7-server) package can be used +The [`nyholm/psr7-server`](https://github.com/Nyholm/psr7-server) package can be used to create server requests from PHP superglobals. ```bash @@ -74,11 +71,9 @@ composer require nyholm/psr7-server ``` ```php -use Nyholm\Psr7\Factory\Psr17Factory; - -$psr17Factory = new Psr17Factory(); +$psr17Factory = new \Nyholm\Psr7\Factory\Psr17Factory(); -$creator = new ServerRequestCreator( +$creator = new \Nyholm\Psr7Server\ServerRequestCreator( $psr17Factory, // ServerRequestFactory $psr17Factory, // UriFactory $psr17Factory, // UploadedFileFactory @@ -91,20 +86,23 @@ $serverRequest = $creator->fromGlobals(); ### Emitting a response ```bash -composer require zendframework/zend-httphandlerrunner +composer require laminas/laminas-httphandlerrunner ``` ```php -$response = (new Psr17Factory())->createReponse('200', 'Hello world'); -(new \Zend\HttpHandlerRunner\Emitter\SapiEmitter())->emit($response); +$psr17Factory = new \Nyholm\Psr7\Factory\Psr17Factory(); + +$responseBody = $psr17Factory->createStream('Hello world'); +$response = $psr17Factory->createResponse(200)->withBody($responseBody); +(new \Laminas\HttpHandlerRunner\Emitter\SapiEmitter())->emit($response); ``` ## Our goal -This package is currently maintained by [Tobias Nyholm](http://nyholm.se) and +This package is currently maintained by [Tobias Nyholm](http://nyholm.se) and [Martijn van der Ven](https://vanderven.se/martijn/). They have decided that the -goal of this library should be to provide a super strict implementation of -[PSR-7](https://www.php-fig.org/psr/psr-7/) that is blazing fast. +goal of this library should be to provide a super strict implementation of +[PSR-7](https://www.php-fig.org/psr/psr-7/) that is blazing fast. The package will never include any extra features nor helper methods. All our classes -and functions exist because they are required to fulfill the PSR-7 specification. +and functions exist because they are required to fulfill the PSR-7 specification. diff --git a/composer.json b/composer.json index 569cf14..c607615 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "A fast PHP7 implementation of PSR-7", "license": "MIT", "keywords": ["psr-7", "psr-17"], - "homepage": "http://tnyholm.se", + "homepage": "https://tnyholm.se", "authors": [ { "name": "Tobias Nyholm", @@ -15,17 +15,19 @@ } ], "require": { - "php": "^7.1", - "psr/http-message": "^1.0", - "php-http/message-factory": "^1.0", + "php": ">=7.2", + "psr/http-message": "^1.1 || ^2.0", "psr/http-factory": "^1.0" }, "require-dev": { - "phpunit/phpunit": "^7.5", - "php-http/psr7-integration-tests": "dev-master", - "http-interop/http-factory-tests": "dev-master" + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "http-interop/http-factory-tests": "^0.9", + "symfony/error-handler": "^4.4" }, "provide": { + "php-http/message-factory-implementation": "1.0", "psr/http-message-implementation": "1.0", "psr/http-factory-implementation": "1.0" }, @@ -39,13 +41,9 @@ "Tests\\Nyholm\\Psr7\\": "tests/" } }, - "scripts": { - "test": "vendor/bin/phpunit", - "test-ci": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml" - }, "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-master": "1.8-dev" } } } diff --git a/doc/final.md b/doc/final.md new file mode 100644 index 0000000..b5b1942 --- /dev/null +++ b/doc/final.md @@ -0,0 +1,20 @@ +# Final classes + +The `final` keyword was removed in version 1.4.0. It was replaced by `@final` annotation. +This was done due popular demand, not because it is a good technical reason to +extend the classes. + +This document will show the correct way to work with PSR-7 classes. The "correct way" +refers to best practices and good software design. I strongly believe that one should +be aware of how a problem *should* be solved, however, it is not needed to always +implement that solution. + +## Extending classes + +You should never extend the classes, you should rather use composition or implement +the interface yourself. Please refer to the [decorator pattern](https://refactoring.guru/design-patterns/decorator). + +## Mocking classes + +The PSR-7 classes are all value objects and they can be used without mocking. If +one really needs to create a special scenario, one can mock the interface instead. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..9a0cf11 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,36 @@ +parameters: + ignoreErrors: + - + message: "#^Result of && is always false\\.$#" + count: 1 + path: src/Response.php + + - + message: "#^Strict comparison using \\=\\=\\= between null and string will always evaluate to false\\.$#" + count: 1 + path: src/Response.php + + - + message: "#^Result of && is always false\\.$#" + count: 1 + path: src/ServerRequest.php + + - + message: "#^Strict comparison using \\!\\=\\= between null and null will always evaluate to false\\.$#" + count: 1 + path: src/ServerRequest.php + + - + message: "#^Result of && is always false\\.$#" + count: 1 + path: src/Stream.php + + - + message: "#^Result of && is always false\\.$#" + count: 2 + path: src/UploadedFile.php + + - + message: "#^Strict comparison using \\=\\=\\= between false and true will always evaluate to false\\.$#" + count: 2 + path: src/UploadedFile.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index df24667..b9fec19 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,25 +1,7 @@ +includes: + - phpstan-baseline.neon + parameters: level: 5 paths: - src - - ignoreErrors: - - - message: '#Strict comparison using === between false and true will always evaluate to false#' - path: %currentWorkingDirectory%/src/UploadedFile.php - - - - message: '#Strict comparison using === between null and string will always evaluate to false#' - path: %currentWorkingDirectory%/src/Response.php - - - - message: '#Result of && is always false.#' - path: %currentWorkingDirectory%/src/Response.php - - - - message: '#Strict comparison using !== between null and null will always evaluate to false#' - path: %currentWorkingDirectory%/src/ServerRequest.php - - - - message: '#Result of && is always false#' - path: %currentWorkingDirectory%/src/ServerRequest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 100a46c..51ebcd4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,27 +1,19 @@ - - - - tests/ - - - - ./vendor/http-interop/http-factory-tests/test - - - - - - src/ - - - - - - - - - - - + + + + tests/ + + + ./vendor/http-interop/http-factory-tests/test + + + + + + + + + + diff --git a/psalm.baseline.xml b/psalm.baseline.xml new file mode 100644 index 0000000..fe5b92e --- /dev/null +++ b/psalm.baseline.xml @@ -0,0 +1,8 @@ + + + + + return \trigger_error((string) $e, \E_USER_ERROR); + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..de59250 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/src/Factory/HttplugFactory.php b/src/Factory/HttplugFactory.php index a296541..cc9285d 100644 --- a/src/Factory/HttplugFactory.php +++ b/src/Factory/HttplugFactory.php @@ -6,25 +6,38 @@ use Http\Message\{MessageFactory, StreamFactory, UriFactory}; use Nyholm\Psr7\{Request, Response, Stream, Uri}; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UriInterface; +if (!\interface_exists(MessageFactory::class)) { + throw new \LogicException('You cannot use "Nyholm\Psr7\Factory\HttplugFactory" as the "php-http/message-factory" package is not installed. Try running "composer require php-http/message-factory". Note that this package is deprecated, use "psr/http-factory" instead'); +} + +@\trigger_error('Class "Nyholm\Psr7\Factory\HttplugFactory" is deprecated since version 1.8, use "Nyholm\Psr7\Factory\Psr17Factory" instead.', \E_USER_DEPRECATED); + /** * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md + * + * @deprecated since version 1.8, use Psr17Factory instead */ -final class HttplugFactory implements MessageFactory, StreamFactory, UriFactory +class HttplugFactory implements MessageFactory, StreamFactory, UriFactory { - public function createRequest($method, $uri, array $headers = [], $body = null, $protocolVersion = '1.1') + public function createRequest($method, $uri, array $headers = [], $body = null, $protocolVersion = '1.1'): RequestInterface { return new Request($method, $uri, $headers, $body, $protocolVersion); } - public function createResponse($statusCode = 200, $reasonPhrase = null, array $headers = [], $body = null, $version = '1.1') + public function createResponse($statusCode = 200, $reasonPhrase = null, array $headers = [], $body = null, $version = '1.1'): ResponseInterface { return new Response((int) $statusCode, $headers, $body, $version, $reasonPhrase); } - public function createStream($body = null) + public function createStream($body = null): StreamInterface { return Stream::create($body ?? ''); } diff --git a/src/Factory/Psr17Factory.php b/src/Factory/Psr17Factory.php index 33130ce..2fa98be 100644 --- a/src/Factory/Psr17Factory.php +++ b/src/Factory/Psr17Factory.php @@ -10,8 +10,10 @@ /** * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md */ -final class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface +class Psr17Factory implements RequestFactoryInterface, ResponseFactoryInterface, ServerRequestFactoryInterface, StreamFactoryInterface, UploadedFileFactoryInterface, UriFactoryInterface { public function createRequest(string $method, $uri): RequestInterface { @@ -20,6 +22,11 @@ public function createRequest(string $method, $uri): RequestInterface public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface { + if (2 > \func_num_args()) { + // This will make the Response class to use a custom reasonPhrase + $reasonPhrase = null; + } + return new Response($code, [], null, '1.1', $reasonPhrase); } @@ -30,13 +37,16 @@ public function createStream(string $content = ''): StreamInterface public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface { - $resource = @\fopen($filename, $mode); - if (false === $resource) { - if ('' === $mode || false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'])) { - throw new \InvalidArgumentException('The mode ' . $mode . ' is invalid.'); + if ('' === $filename) { + throw new \RuntimeException('Path cannot be empty'); + } + + if (false === $resource = @\fopen($filename, $mode)) { + if ('' === $mode || false === \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true)) { + throw new \InvalidArgumentException(\sprintf('The mode "%s" is invalid.', $mode)); } - throw new \RuntimeException('The file ' . $filename . ' cannot be opened.'); + throw new \RuntimeException(\sprintf('The file "%s" cannot be opened: %s', $filename, \error_get_last()['message'] ?? '')); } return Stream::create($resource); @@ -47,7 +57,7 @@ public function createStreamFromResource($resource): StreamInterface return Stream::create($resource); } - public function createUploadedFile(StreamInterface $stream, int $size = null, int $error = \UPLOAD_ERR_OK, string $clientFilename = null, string $clientMediaType = null): UploadedFileInterface + public function createUploadedFile(StreamInterface $stream, ?int $size = null, int $error = \UPLOAD_ERR_OK, ?string $clientFilename = null, ?string $clientMediaType = null): UploadedFileInterface { if (null === $size) { $size = $stream->getSize(); diff --git a/src/MessageTrait.php b/src/MessageTrait.php index d1e93cc..7d02383 100644 --- a/src/MessageTrait.php +++ b/src/MessageTrait.php @@ -4,6 +4,7 @@ namespace Nyholm\Psr7; +use Psr\Http\Message\MessageInterface; use Psr\Http\Message\StreamInterface; /** @@ -34,14 +35,21 @@ public function getProtocolVersion(): string return $this->protocol; } - public function withProtocolVersion($version): self + /** + * @return static + */ + public function withProtocolVersion($version): MessageInterface { + if (!\is_scalar($version)) { + throw new \InvalidArgumentException('Protocol version must be a string'); + } + if ($this->protocol === $version) { return $this; } $new = clone $this; - $new->protocol = $version; + $new->protocol = (string) $version; return $new; } @@ -53,12 +61,16 @@ public function getHeaders(): array public function hasHeader($header): bool { - return isset($this->headerNames[\strtolower($header)]); + return isset($this->headerNames[\strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')]); } public function getHeader($header): array { - $header = \strtolower($header); + if (!\is_string($header)) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string'); + } + + $header = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); if (!isset($this->headerNames[$header])) { return []; } @@ -73,10 +85,13 @@ public function getHeaderLine($header): string return \implode(', ', $this->getHeader($header)); } - public function withHeader($header, $value): self + /** + * @return static + */ + public function withHeader($header, $value): MessageInterface { $value = $this->validateAndTrimHeader($header, $value); - $normalized = \strtolower($header); + $normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); $new = clone $this; if (isset($new->headerNames[$normalized])) { @@ -88,10 +103,13 @@ public function withHeader($header, $value): self return $new; } - public function withAddedHeader($header, $value): self + /** + * @return static + */ + public function withAddedHeader($header, $value): MessageInterface { if (!\is_string($header) || '' === $header) { - throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string'); } $new = clone $this; @@ -100,9 +118,16 @@ public function withAddedHeader($header, $value): self return $new; } - public function withoutHeader($header): self + /** + * @return static + */ + public function withoutHeader($header): MessageInterface { - $normalized = \strtolower($header); + if (!\is_string($header)) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string'); + } + + $normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); if (!isset($this->headerNames[$normalized])) { return $this; } @@ -123,7 +148,10 @@ public function getBody(): StreamInterface return $this->stream; } - public function withBody(StreamInterface $body): self + /** + * @return static + */ + public function withBody(StreamInterface $body): MessageInterface { if ($body === $this->stream) { return $this; @@ -138,8 +166,13 @@ public function withBody(StreamInterface $body): self private function setHeaders(array $headers): void { foreach ($headers as $header => $value) { + if (\is_int($header)) { + // If a header name was set to a numeric string, PHP will cast the key to an int. + // We must cast it back to a string in order to comply with validation. + $header = (string) $header; + } $value = $this->validateAndTrimHeader($header, $value); - $normalized = \strtolower($header); + $normalized = \strtr($header, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); if (isset($this->headerNames[$normalized])) { $header = $this->headerNames[$normalized]; $this->headers[$header] = \array_merge($this->headers[$header], $value); @@ -170,28 +203,28 @@ private function setHeaders(array $headers): void */ private function validateAndTrimHeader($header, $values): array { - if (!\is_string($header) || 1 !== \preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@", $header)) { - throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string.'); + if (!\is_string($header) || 1 !== \preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@D", $header)) { + throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string'); } if (!\is_array($values)) { // This is simple, just one value. if ((!\is_numeric($values) && !\is_string($values)) || 1 !== \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $values)) { - throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings'); } return [\trim((string) $values, " \t")]; } if (empty($values)) { - throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.'); + throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given'); } // Assert Non empty array $returnValues = []; foreach ($values as $v) { - if ((!\is_numeric($v) && !\is_string($v)) || 1 !== \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $v)) { - throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings.'); + if ((!\is_numeric($v) && !\is_string($v)) || 1 !== \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@D", (string) $v)) { + throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings'); } $returnValues[] = \trim((string) $v, " \t"); diff --git a/src/Request.php b/src/Request.php index 84a9f2a..d50744e 100644 --- a/src/Request.php +++ b/src/Request.php @@ -9,8 +9,10 @@ /** * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md */ -final class Request implements RequestInterface +class Request implements RequestInterface { use MessageTrait; use RequestTrait; diff --git a/src/RequestTrait.php b/src/RequestTrait.php index f39993a..2dbb3ab 100644 --- a/src/RequestTrait.php +++ b/src/RequestTrait.php @@ -4,6 +4,7 @@ namespace Nyholm\Psr7; +use Psr\Http\Message\RequestInterface; use Psr\Http\Message\UriInterface; /** @@ -40,8 +41,15 @@ public function getRequestTarget(): string return $target; } - public function withRequestTarget($requestTarget): self + /** + * @return static + */ + public function withRequestTarget($requestTarget): RequestInterface { + if (!\is_string($requestTarget)) { + throw new \InvalidArgumentException('Request target must be a string'); + } + if (\preg_match('#\s#', $requestTarget)) { throw new \InvalidArgumentException('Invalid request target provided; cannot contain whitespace'); } @@ -57,7 +65,10 @@ public function getMethod(): string return $this->method; } - public function withMethod($method): self + /** + * @return static + */ + public function withMethod($method): RequestInterface { if (!\is_string($method)) { throw new \InvalidArgumentException('Method must be a string'); @@ -74,7 +85,10 @@ public function getUri(): UriInterface return $this->uri; } - public function withUri(UriInterface $uri, $preserveHost = false): self + /** + * @return static + */ + public function withUri(UriInterface $uri, $preserveHost = false): RequestInterface { if ($uri === $this->uri) { return $this; diff --git a/src/Response.php b/src/Response.php index f50f188..71eb2fa 100644 --- a/src/Response.php +++ b/src/Response.php @@ -10,8 +10,10 @@ * @author Michael Dowling and contributors to guzzlehttp/psr7 * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md */ -final class Response implements ResponseInterface +class Response implements ResponseInterface { use MessageTrait; @@ -37,7 +39,7 @@ final class Response implements ResponseInterface * @param string $version Protocol version * @param string|null $reason Reason phrase (when empty a default will be used based on the status code) */ - public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', string $reason = null) + public function __construct(int $status = 200, array $headers = [], $body = null, string $version = '1.1', ?string $reason = null) { // If we got no body, defer initialization of the stream until Response::getBody() if ('' !== $body && null !== $body) { @@ -49,7 +51,7 @@ public function __construct(int $status = 200, array $headers = [], $body = null if (null === $reason && isset(self::PHRASES[$this->statusCode])) { $this->reasonPhrase = self::PHRASES[$status]; } else { - $this->reasonPhrase = $reason; + $this->reasonPhrase = $reason ?? ''; } $this->protocol = $version; @@ -65,7 +67,10 @@ public function getReasonPhrase(): string return $this->reasonPhrase; } - public function withStatus($code, $reasonPhrase = ''): self + /** + * @return static + */ + public function withStatus($code, $reasonPhrase = ''): ResponseInterface { if (!\is_int($code) && !\is_string($code)) { throw new \InvalidArgumentException('Status code has to be an integer'); @@ -73,7 +78,7 @@ public function withStatus($code, $reasonPhrase = ''): self $code = (int) $code; if ($code < 100 || $code > 599) { - throw new \InvalidArgumentException('Status code has to be an integer between 100 and 599'); + throw new \InvalidArgumentException(\sprintf('Status code has to be an integer between 100 and 599. A status code of %d was given', $code)); } $new = clone $this; diff --git a/src/ServerRequest.php b/src/ServerRequest.php index aff8721..a3c5ba9 100644 --- a/src/ServerRequest.php +++ b/src/ServerRequest.php @@ -10,8 +10,10 @@ * @author Michael Dowling and contributors to guzzlehttp/psr7 * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md */ -final class ServerRequest implements ServerRequestInterface +class ServerRequest implements ServerRequestInterface { use MessageTrait; use RequestTrait; @@ -54,6 +56,7 @@ public function __construct(string $method, $uri, array $headers = [], $body = n $this->uri = $uri; $this->setHeaders($headers); $this->protocol = $version; + \parse_str($uri->getQuery(), $this->queryParams); if (!$this->hasHeader('Host')) { $this->updateHostFromUri(); @@ -75,7 +78,10 @@ public function getUploadedFiles(): array return $this->uploadedFiles; } - public function withUploadedFiles(array $uploadedFiles) + /** + * @return static + */ + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface { $new = clone $this; $new->uploadedFiles = $uploadedFiles; @@ -88,7 +94,10 @@ public function getCookieParams(): array return $this->cookieParams; } - public function withCookieParams(array $cookies) + /** + * @return static + */ + public function withCookieParams(array $cookies): ServerRequestInterface { $new = clone $this; $new->cookieParams = $cookies; @@ -101,7 +110,10 @@ public function getQueryParams(): array return $this->queryParams; } - public function withQueryParams(array $query) + /** + * @return static + */ + public function withQueryParams(array $query): ServerRequestInterface { $new = clone $this; $new->queryParams = $query; @@ -109,12 +121,18 @@ public function withQueryParams(array $query) return $new; } + /** + * @return array|object|null + */ public function getParsedBody() { return $this->parsedBody; } - public function withParsedBody($data) + /** + * @return static + */ + public function withParsedBody($data): ServerRequestInterface { if (!\is_array($data) && !\is_object($data) && null !== $data) { throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null'); @@ -131,8 +149,15 @@ public function getAttributes(): array return $this->attributes; } + /** + * @return mixed + */ public function getAttribute($attribute, $default = null) { + if (!\is_string($attribute)) { + throw new \InvalidArgumentException('Attribute name must be a string'); + } + if (false === \array_key_exists($attribute, $this->attributes)) { return $default; } @@ -140,16 +165,30 @@ public function getAttribute($attribute, $default = null) return $this->attributes[$attribute]; } - public function withAttribute($attribute, $value): self + /** + * @return static + */ + public function withAttribute($attribute, $value): ServerRequestInterface { + if (!\is_string($attribute)) { + throw new \InvalidArgumentException('Attribute name must be a string'); + } + $new = clone $this; $new->attributes[$attribute] = $value; return $new; } - public function withoutAttribute($attribute): self + /** + * @return static + */ + public function withoutAttribute($attribute): ServerRequestInterface { + if (!\is_string($attribute)) { + throw new \InvalidArgumentException('Attribute name must be a string'); + } + if (false === \array_key_exists($attribute, $this->attributes)) { return $this; } diff --git a/src/Stream.php b/src/Stream.php index e877e5d..63b7d6d 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -10,9 +10,13 @@ * @author Michael Dowling and contributors to guzzlehttp/psr7 * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md */ -final class Stream implements StreamInterface +class Stream implements StreamInterface { + use StreamTrait; + /** @var resource|null A resource reference */ private $stream; @@ -25,7 +29,7 @@ final class Stream implements StreamInterface /** @var bool */ private $writable; - /** @var array|mixed|void|null */ + /** @var array|mixed|void|bool|null */ private $uri; /** @var int|null */ @@ -47,8 +51,20 @@ final class Stream implements StreamInterface ], ]; - private function __construct() + /** + * @param resource $body + */ + public function __construct($body) { + if (!\is_resource($body)) { + throw new \InvalidArgumentException('First argument to Stream::__construct() must be resource'); + } + + $this->stream = $body; + $meta = \stream_get_meta_data($this->stream); + $this->seekable = $meta['seekable'] && 0 === \fseek($this->stream, 0, \SEEK_CUR); + $this->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); + $this->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); } /** @@ -56,8 +72,6 @@ private function __construct() * * @param string|resource|StreamInterface $body * - * @return StreamInterface - * * @throws \InvalidArgumentException */ public static function create($body = ''): StreamInterface @@ -67,24 +81,21 @@ public static function create($body = ''): StreamInterface } if (\is_string($body)) { - $resource = \fopen('php://temp', 'rw+'); - \fwrite($resource, $body); - $body = $resource; + if (200000 <= \strlen($body)) { + $body = self::openZvalStream($body); + } else { + $resource = \fopen('php://memory', 'r+'); + \fwrite($resource, $body); + \fseek($resource, 0); + $body = $resource; + } } - if (\is_resource($body)) { - $new = new self(); - $new->stream = $body; - $meta = \stream_get_meta_data($new->stream); - $new->seekable = $meta['seekable']; - $new->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); - $new->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); - $new->uri = $new->getMetadata('uri'); - - return $new; + if (!\is_resource($body)) { + throw new \InvalidArgumentException('First argument to Stream::create() must be a string, resource or StreamInterface'); } - throw new \InvalidArgumentException('First argument to Stream::create() must be a string, resource or StreamInterface.'); + return new self($body); } /** @@ -95,19 +106,6 @@ public function __destruct() $this->close(); } - public function __toString(): string - { - try { - if ($this->isSeekable()) { - $this->seek(0); - } - - return $this->getContents(); - } catch (\Exception $e) { - return ''; - } - } - public function close(): void { if (isset($this->stream)) { @@ -132,6 +130,15 @@ public function detach() return $result; } + private function getUri() + { + if (false !== $this->uri) { + $this->uri = $this->getMetadata('uri') ?? false; + } + + return $this->uri; + } + public function getSize(): ?int { if (null !== $this->size) { @@ -143,8 +150,8 @@ public function getSize(): ?int } // Clear the stat cache if the stream has a URI - if ($this->uri) { - \clearstatcache(true, $this->uri); + if ($uri = $this->getUri()) { + \clearstatcache(true, $uri); } $stats = \fstat($this->stream); @@ -159,8 +166,12 @@ public function getSize(): ?int public function tell(): int { - if (false === $result = \ftell($this->stream)) { - throw new \RuntimeException('Unable to determine stream position'); + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + + if (false === $result = @\ftell($this->stream)) { + throw new \RuntimeException('Unable to determine stream position: ' . (\error_get_last()['message'] ?? '')); } return $result; @@ -168,7 +179,7 @@ public function tell(): int public function eof(): bool { - return !$this->stream || \feof($this->stream); + return !isset($this->stream) || \feof($this->stream); } public function isSeekable(): bool @@ -178,12 +189,16 @@ public function isSeekable(): bool public function seek($offset, $whence = \SEEK_SET): void { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + if (!$this->seekable) { throw new \RuntimeException('Stream is not seekable'); } if (-1 === \fseek($this->stream, $offset, $whence)) { - throw new \RuntimeException('Unable to seek to stream position ' . $offset . ' with whence ' . \var_export($whence, true)); + throw new \RuntimeException('Unable to seek to stream position "' . $offset . '" with whence ' . \var_export($whence, true)); } } @@ -199,6 +214,10 @@ public function isWritable(): bool public function write($string): int { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + if (!$this->writable) { throw new \RuntimeException('Cannot write to a non-writable stream'); } @@ -206,8 +225,8 @@ public function write($string): int // We can't know the size after writing anything $this->size = null; - if (false === $result = \fwrite($this->stream, $string)) { - throw new \RuntimeException('Unable to write to stream'); + if (false === $result = @\fwrite($this->stream, $string)) { + throw new \RuntimeException('Unable to write to stream: ' . (\error_get_last()['message'] ?? '')); } return $result; @@ -220,28 +239,51 @@ public function isReadable(): bool public function read($length): string { + if (!isset($this->stream)) { + throw new \RuntimeException('Stream is detached'); + } + if (!$this->readable) { throw new \RuntimeException('Cannot read from non-readable stream'); } - return \fread($this->stream, $length); + if (false === $result = @\fread($this->stream, $length)) { + throw new \RuntimeException('Unable to read from stream: ' . (\error_get_last()['message'] ?? '')); + } + + return $result; } public function getContents(): string { if (!isset($this->stream)) { - throw new \RuntimeException('Unable to read stream contents'); + throw new \RuntimeException('Stream is detached'); } - if (false === $contents = \stream_get_contents($this->stream)) { - throw new \RuntimeException('Unable to read stream contents'); - } + $exception = null; - return $contents; + \set_error_handler(static function ($type, $message) use (&$exception) { + throw $exception = new \RuntimeException('Unable to read stream contents: ' . $message); + }); + + try { + return \stream_get_contents($this->stream); + } catch (\Throwable $e) { + throw $e === $exception ? $e : new \RuntimeException('Unable to read stream contents: ' . $e->getMessage(), 0, $e); + } finally { + \restore_error_handler(); + } } + /** + * @return mixed + */ public function getMetadata($key = null) { + if (null !== $key && !\is_string($key)) { + throw new \InvalidArgumentException('Metadata key must be a string'); + } + if (!isset($this->stream)) { return $key ? null : []; } @@ -254,4 +296,104 @@ public function getMetadata($key = null) return $meta[$key] ?? null; } + + private static function openZvalStream(string $body) + { + static $wrapper; + + $wrapper ?? \stream_wrapper_register('Nyholm-Psr7-Zval', $wrapper = \get_class(new class() { + public $context; + + private $data; + private $position = 0; + + public function stream_open(): bool + { + $this->data = \stream_context_get_options($this->context)['Nyholm-Psr7-Zval']['data']; + \stream_context_set_option($this->context, 'Nyholm-Psr7-Zval', 'data', null); + + return true; + } + + public function stream_read(int $count): string + { + $result = \substr($this->data, $this->position, $count); + $this->position += \strlen($result); + + return $result; + } + + public function stream_write(string $data): int + { + $this->data = \substr_replace($this->data, $data, $this->position, \strlen($data)); + $this->position += \strlen($data); + + return \strlen($data); + } + + public function stream_tell(): int + { + return $this->position; + } + + public function stream_eof(): bool + { + return \strlen($this->data) <= $this->position; + } + + public function stream_stat(): array + { + return [ + 'mode' => 33206, // POSIX_S_IFREG | 0666 + 'nlink' => 1, + 'rdev' => -1, + 'size' => \strlen($this->data), + 'blksize' => -1, + 'blocks' => -1, + ]; + } + + public function stream_seek(int $offset, int $whence): bool + { + if (\SEEK_SET === $whence && (0 <= $offset && \strlen($this->data) >= $offset)) { + $this->position = $offset; + } elseif (\SEEK_CUR === $whence && 0 <= $offset) { + $this->position += $offset; + } elseif (\SEEK_END === $whence && (0 > $offset && 0 <= $offset = \strlen($this->data) + $offset)) { + $this->position = $offset; + } else { + return false; + } + + return true; + } + + public function stream_set_option(): bool + { + return true; + } + + public function stream_truncate(int $new_size): bool + { + if ($new_size) { + $this->data = \substr($this->data, 0, $new_size); + $this->position = \min($this->position, $new_size); + } else { + $this->data = ''; + $this->position = 0; + } + + return true; + } + })); + + $context = \stream_context_create(['Nyholm-Psr7-Zval' => ['data' => $body]]); + + if (!$stream = @\fopen('Nyholm-Psr7-Zval://', 'r+', false, $context)) { + \stream_wrapper_register('Nyholm-Psr7-Zval', $wrapper); + $stream = \fopen('Nyholm-Psr7-Zval://', 'r+', false, $context); + } + + return $stream; + } } diff --git a/src/StreamTrait.php b/src/StreamTrait.php new file mode 100644 index 0000000..41a3f9d --- /dev/null +++ b/src/StreamTrait.php @@ -0,0 +1,57 @@ += 70400 || (new \ReflectionMethod(StreamInterface::class, '__toString'))->hasReturnType()) { + /** + * @internal + */ + trait StreamTrait + { + public function __toString(): string + { + if ($this->isSeekable()) { + $this->seek(0); + } + + return $this->getContents(); + } + } +} else { + /** + * @internal + */ + trait StreamTrait + { + /** + * @return string + */ + public function __toString() + { + try { + if ($this->isSeekable()) { + $this->seek(0); + } + + return $this->getContents(); + } catch (\Throwable $e) { + if (\is_array($errorHandler = \set_error_handler('var_dump'))) { + $errorHandler = $errorHandler[0] ?? null; + } + \restore_error_handler(); + + if ($e instanceof \Error || $errorHandler instanceof SymfonyErrorHandler || $errorHandler instanceof SymfonyLegacyErrorHandler) { + return \trigger_error((string) $e, \E_USER_ERROR); + } + + return ''; + } + } + } +} diff --git a/src/UploadedFile.php b/src/UploadedFile.php index 757e70e..c77dca4 100644 --- a/src/UploadedFile.php +++ b/src/UploadedFile.php @@ -10,8 +10,10 @@ * @author Michael Dowling and contributors to guzzlehttp/psr7 * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md */ -final class UploadedFile implements UploadedFileInterface +class UploadedFile implements UploadedFileInterface { /** @var array */ private const ERRORS = [ @@ -56,7 +58,7 @@ final class UploadedFile implements UploadedFileInterface public function __construct($streamOrFile, $size, $errorStatus, $clientFilename = null, $clientMediaType = null) { if (false === \is_int($errorStatus) || !isset(self::ERRORS[$errorStatus])) { - throw new \InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants.'); + throw new \InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants'); } if (false === \is_int($size)) { @@ -78,7 +80,7 @@ public function __construct($streamOrFile, $size, $errorStatus, $clientFilename if (\UPLOAD_ERR_OK === $this->error) { // Depending on the value set file or stream variable. - if (\is_string($streamOrFile)) { + if (\is_string($streamOrFile) && '' !== $streamOrFile) { $this->file = $streamOrFile; } elseif (\is_resource($streamOrFile)) { $this->stream = Stream::create($streamOrFile); @@ -112,7 +114,9 @@ public function getStream(): StreamInterface return $this->stream; } - $resource = \fopen($this->file, 'r'); + if (false === $resource = @\fopen($this->file, 'r')) { + throw new \RuntimeException(\sprintf('The file "%s" cannot be opened: %s', $this->file, \error_get_last()['message'] ?? '')); + } return Stream::create($resource); } @@ -126,15 +130,23 @@ public function moveTo($targetPath): void } if (null !== $this->file) { - $this->moved = 'cli' === \PHP_SAPI ? \rename($this->file, $targetPath) : \move_uploaded_file($this->file, $targetPath); + $this->moved = 'cli' === \PHP_SAPI ? @\rename($this->file, $targetPath) : @\move_uploaded_file($this->file, $targetPath); + + if (false === $this->moved) { + throw new \RuntimeException(\sprintf('Uploaded file could not be moved to "%s": %s', $targetPath, \error_get_last()['message'] ?? '')); + } } else { $stream = $this->getStream(); if ($stream->isSeekable()) { $stream->rewind(); } - // Copy the contents of a stream into another stream until end-of-file. - $dest = Stream::create(\fopen($targetPath, 'w')); + if (false === $resource = @\fopen($targetPath, 'w')) { + throw new \RuntimeException(\sprintf('The file "%s" cannot be opened: %s', $targetPath, \error_get_last()['message'] ?? '')); + } + + $dest = Stream::create($resource); + while (!$stream->eof()) { if (!$dest->write($stream->read(1048576))) { break; @@ -143,10 +155,6 @@ public function moveTo($targetPath): void $this->moved = true; } - - if (false === $this->moved) { - throw new \RuntimeException(\sprintf('Uploaded file could not be moved to %s', $targetPath)); - } } public function getSize(): int diff --git a/src/Uri.php b/src/Uri.php index 1775360..621e2e7 100644 --- a/src/Uri.php +++ b/src/Uri.php @@ -14,8 +14,10 @@ * @author Matthew Weier O'Phinney * @author Tobias Nyholm * @author Martijn van der Ven + * + * @final This class should never be extended. See https://github.com/Nyholm/psr7/blob/master/doc/final.md */ -final class Uri implements UriInterface +class Uri implements UriInterface { private const SCHEMES = ['http' => 80, 'https' => 443]; @@ -23,6 +25,8 @@ final class Uri implements UriInterface private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; + private const CHAR_GEN_DELIMS = ':\/\?#\[\]@'; + /** @var string Uri scheme. */ private $scheme = ''; @@ -48,13 +52,13 @@ public function __construct(string $uri = '') { if ('' !== $uri) { if (false === $parts = \parse_url($uri)) { - throw new \InvalidArgumentException("Unable to parse URI: $uri"); + throw new \InvalidArgumentException(\sprintf('Unable to parse URI: "%s"', $uri)); } // Apply parse_url parts to a URI. - $this->scheme = isset($parts['scheme']) ? \strtolower($parts['scheme']) : ''; + $this->scheme = isset($parts['scheme']) ? \strtr($parts['scheme'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : ''; $this->userInfo = $parts['user'] ?? ''; - $this->host = isset($parts['host']) ? \strtolower($parts['host']) : ''; + $this->host = isset($parts['host']) ? \strtr($parts['host'], 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') : ''; $this->port = isset($parts['port']) ? $this->filterPort($parts['port']) : null; $this->path = isset($parts['path']) ? $this->filterPath($parts['path']) : ''; $this->query = isset($parts['query']) ? $this->filterQueryAndFragment($parts['query']) : ''; @@ -110,7 +114,20 @@ public function getPort(): ?int public function getPath(): string { - return $this->path; + $path = $this->path; + + if ('' !== $path && '/' !== $path[0]) { + if ('' !== $this->host) { + // If the path is rootless and an authority is present, the path MUST be prefixed by "/" + $path = '/' . $path; + } + } elseif (isset($path[1]) && '/' === $path[1]) { + // If the path is starting with more than one "/", the + // starting slashes MUST be reduced to one. + $path = '/' . \ltrim($path, '/'); + } + + return $path; } public function getQuery(): string @@ -123,13 +140,16 @@ public function getFragment(): string return $this->fragment; } - public function withScheme($scheme): self + /** + * @return static + */ + public function withScheme($scheme): UriInterface { if (!\is_string($scheme)) { throw new \InvalidArgumentException('Scheme must be a string'); } - if ($this->scheme === $scheme = \strtolower($scheme)) { + if ($this->scheme === $scheme = \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) { return $this; } @@ -140,11 +160,22 @@ public function withScheme($scheme): self return $new; } - public function withUserInfo($user, $password = null): self + /** + * @return static + */ + public function withUserInfo($user, $password = null): UriInterface { - $info = $user; + if (!\is_string($user)) { + throw new \InvalidArgumentException('User must be a string'); + } + + $info = \preg_replace_callback('/[' . self::CHAR_GEN_DELIMS . self::CHAR_SUB_DELIMS . ']++/', [__CLASS__, 'rawurlencodeMatchZero'], $user); if (null !== $password && '' !== $password) { - $info .= ':' . $password; + if (!\is_string($password)) { + throw new \InvalidArgumentException('Password must be a string'); + } + + $info .= ':' . \preg_replace_callback('/[' . self::CHAR_GEN_DELIMS . self::CHAR_SUB_DELIMS . ']++/', [__CLASS__, 'rawurlencodeMatchZero'], $password); } if ($this->userInfo === $info) { @@ -157,13 +188,16 @@ public function withUserInfo($user, $password = null): self return $new; } - public function withHost($host): self + /** + * @return static + */ + public function withHost($host): UriInterface { if (!\is_string($host)) { throw new \InvalidArgumentException('Host must be a string'); } - if ($this->host === $host = \strtolower($host)) { + if ($this->host === $host = \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')) { return $this; } @@ -173,7 +207,10 @@ public function withHost($host): self return $new; } - public function withPort($port): self + /** + * @return static + */ + public function withPort($port): UriInterface { if ($this->port === $port = $this->filterPort($port)) { return $this; @@ -185,7 +222,10 @@ public function withPort($port): self return $new; } - public function withPath($path): self + /** + * @return static + */ + public function withPath($path): UriInterface { if ($this->path === $path = $this->filterPath($path)) { return $this; @@ -197,7 +237,10 @@ public function withPath($path): self return $new; } - public function withQuery($query): self + /** + * @return static + */ + public function withQuery($query): UriInterface { if ($this->query === $query = $this->filterQueryAndFragment($query)) { return $this; @@ -209,7 +252,10 @@ public function withQuery($query): self return $new; } - public function withFragment($fragment): self + /** + * @return static + */ + public function withFragment($fragment): UriInterface { if ($this->fragment === $fragment = $this->filterQueryAndFragment($fragment)) { return $this; @@ -278,8 +324,8 @@ private function filterPort($port): ?int } $port = (int) $port; - if (1 > $port || 0xffff < $port) { - throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 1 and 65535', $port)); + if (0 > $port || 0xFFFF < $port) { + throw new \InvalidArgumentException(\sprintf('Invalid port: %d. Must be between 0 and 65535', $port)); } return self::isNonStandardPort($this->scheme, $port) ? $port : null; diff --git a/tests/Factory/Psr17FactoryTest.php b/tests/Factory/Psr17FactoryTest.php new file mode 100644 index 0000000..9ba03de --- /dev/null +++ b/tests/Factory/Psr17FactoryTest.php @@ -0,0 +1,38 @@ +createResponse(200); + $this->assertEquals('OK', $r->getReasonPhrase()); + + $r = $factory->createResponse(200, ''); + $this->assertEquals('', $r->getReasonPhrase()); + + $r = $factory->createResponse(200, 'Foo'); + $this->assertEquals('Foo', $r->getReasonPhrase()); + + /* + * Test for non-standard response codes + */ + $r = $factory->createResponse(567); + $this->assertEquals('', $r->getReasonPhrase()); + + $r = $factory->createResponse(567, ''); + $this->assertEquals(567, $r->getStatusCode()); + $this->assertEquals('', $r->getReasonPhrase()); + + $r = $factory->createResponse(567, 'Foo'); + $this->assertEquals(567, $r->getStatusCode()); + $this->assertEquals('Foo', $r->getReasonPhrase()); + } +} diff --git a/tests/Integration/RequestTest.php b/tests/Integration/RequestTest.php index 50a99dc..1f0c880 100644 --- a/tests/Integration/RequestTest.php +++ b/tests/Integration/RequestTest.php @@ -1,13 +1,14 @@ -createUploadedFile(Stream::create('writing to tempfile')); } diff --git a/tests/Integration/UriTest.php b/tests/Integration/UriTest.php index d96c26b..ed17568 100644 --- a/tests/Integration/UriTest.php +++ b/tests/Integration/UriTest.php @@ -1,13 +1,14 @@ -expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unable to parse URI: ///'); + $this->expectExceptionMessage('Unable to parse URI: "///"'); new Request('GET', '///'); } @@ -202,6 +202,51 @@ public function testSupportNumericHeaders() $this->assertSame('200', $r->getHeaderLine('Content-Length')); } + public function testSupportNumericHeaderNames() + { + $r = new Request( + 'GET', '', [ + '200' => 'NumericHeaderValue', + '0' => 'NumericHeaderValueZero', + ] + ); + + $this->assertSame( + [ + '200' => ['NumericHeaderValue'], + '0' => ['NumericHeaderValueZero'], + ], + $r->getHeaders() + ); + + $this->assertSame(['NumericHeaderValue'], $r->getHeader('200')); + $this->assertSame('NumericHeaderValue', $r->getHeaderLine('200')); + + $this->assertSame(['NumericHeaderValueZero'], $r->getHeader('0')); + $this->assertSame('NumericHeaderValueZero', $r->getHeaderLine('0')); + + $r = $r->withHeader('300', 'NumericHeaderValue2') + ->withAddedHeader('200', ['A', 'B']); + + $this->assertSame( + [ + '200' => ['NumericHeaderValue', 'A', 'B'], + '0' => ['NumericHeaderValueZero'], + '300' => ['NumericHeaderValue2'], + ], + $r->getHeaders() + ); + + $r = $r->withoutHeader('300'); + $this->assertSame( + [ + '200' => ['NumericHeaderValue', 'A', 'B'], + '0' => ['NumericHeaderValueZero'], + ], + $r->getHeaders() + ); + } + public function testAddsPortToHeader() { $r = new Request('GET', 'http://foo.com:8124/bar'); @@ -218,7 +263,7 @@ public function testAddsPortToHeaderAndReplacePreviousPort() public function testCannotHaveHeaderWithEmptyName() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Header name must be an RFC 7230 compatible string.'); + $this->expectExceptionMessage('Header name must be an RFC 7230 compatible string'); $r = new Request('GET', 'https://example.com/'); $r->withHeader('', 'Bar'); } @@ -249,4 +294,50 @@ public function testUpdateHostFromUri() $request = $request->withUri(new Uri('https://nyholm.tech:443')); $this->assertEquals('nyholm.tech', $request->getHeaderLine('Host')); } + + /** + * @dataProvider provideHeaderValuesContainingNotAllowedChars + */ + public function testCannotHaveHeaderWithInvalidValue(string $name) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Header name must be an RFC 7230 compatible string'); + $r = new Request('GET', 'https://example.com/'); + $r->withHeader($name, 'Bar'); + } + + public static function provideHeaderValuesContainingNotAllowedChars(): array + { + // Explicit tests for newlines as the most common exploit vector. + $tests = [ + ["new\nline"], + ["new\r\nline"], + ["new\rline"], + ["new\r\n line"], + ["newline\n"], + ["\nnewline"], + ["newline\r\n"], + ["\n\rnewline"], + ]; + + for ($i = 0; $i <= 0xFF; ++$i) { + if ("\t" == \chr($i)) { + continue; + } + if (' ' == \chr($i)) { + continue; + } + if ($i >= 0x21 && $i <= 0x7E) { + continue; + } + if ($i >= 0x80) { + continue; + } + + $tests[] = ['foo' . \chr($i) . 'bar']; + $tests[] = ['foo' . \chr($i)]; + } + + return $tests; + } } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 150da53..7f20828 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -23,6 +23,14 @@ public function testDefaultConstructor() $this->assertSame('', (string) $r->getBody()); } + public function testDefaultConstructorSeekBody() + { + $r = new Response(200, [], 'Hello'); + $this->assertSame(200, $r->getStatusCode()); + $this->assertInstanceOf(StreamInterface::class, $r->getBody()); + $this->assertSame('Hello', $r->getBody()->getContents()); + } + public function testCanConstructWithStatusCode() { $r = new Response(404); @@ -30,6 +38,20 @@ public function testCanConstructWithStatusCode() $this->assertSame('Not Found', $r->getReasonPhrase()); } + public function testCanConstructWithUndefinedStatusCode() + { + $r = new Response(999); + $this->assertSame(999, $r->getStatusCode()); + $this->assertSame('', $r->getReasonPhrase()); + } + + public function testCanConstructWithStatusCodeAndEmptyReason() + { + $r = new Response(404, [], null, '1.1', ''); + $this->assertSame(404, $r->getStatusCode()); + $this->assertSame('', $r->getReasonPhrase()); + } + public function testConstructorDoesNotReadStreamBody() { $body = $this->getMockBuilder(StreamInterface::class)->getMock(); @@ -234,7 +256,7 @@ public function testSameInstanceWhenRemovingMissingHeader() $this->assertSame($r, $r->withoutHeader('foo')); } - public function trimmedHeaderValues() + public static function trimmedHeaderValues() { return [ [new Response(200, ['OWS' => " \t \tFoo\t \t "])], @@ -252,4 +274,35 @@ public function testHeaderValuesAreTrimmed($r) $this->assertSame('Foo', $r->getHeaderLine('OWS')); $this->assertSame(['Foo'], $r->getHeader('OWS')); } + + /** + * @dataProvider invalidWithHeaderProvider + */ + public function testWithInvalidHeader($header, $headerValue, $expectedMessage): void + { + $r = new Response(); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expectedMessage); + $r->withHeader($header, $headerValue); + } + + public function invalidWithHeaderProvider(): iterable + { + return [ + ['foo', [], 'Header values must be a string or an array of strings, empty array given'], + ['foo', new \stdClass(), 'Header values must be RFC 7230 compatible strings'], + [[], 'foo', 'Header name must be an RFC 7230 compatible string'], + [false, 'foo', 'Header name must be an RFC 7230 compatible string'], + [new \stdClass(), 'foo', 'Header name must be an RFC 7230 compatible string'], + ['', 'foo', 'Header name must be an RFC 7230 compatible string'], + ["Content-Type\r\n\r\n", 'foo', 'Header name must be an RFC 7230 compatible string'], + ["Content-Type\r\n", 'foo', 'Header name must be an RFC 7230 compatible string'], + ["Content-Type\n", 'foo', 'Header name must be an RFC 7230 compatible string'], + ["\r\nContent-Type", 'foo', 'Header name must be an RFC 7230 compatible string'], + ["\nContent-Type", 'foo', 'Header name must be an RFC 7230 compatible string'], + ["\n", 'foo', 'Header name must be an RFC 7230 compatible string'], + ["\r\n", 'foo', 'Header name must be an RFC 7230 compatible string'], + ["\t", 'foo', 'Header name must be an RFC 7230 compatible string'], + ]; + } } diff --git a/tests/ServerRequestTest.php b/tests/ServerRequestTest.php index c26b228..2fabcb5 100644 --- a/tests/ServerRequestTest.php +++ b/tests/ServerRequestTest.php @@ -1,4 +1,4 @@ - new UploadedFile('test', 123, UPLOAD_ERR_OK), + 'file' => new UploadedFile('test', 123, \UPLOAD_ERR_OK), ]; $request2 = $request1->withUploadedFiles($files); @@ -49,14 +49,15 @@ public function testCookieParams() public function testQueryParams() { - $request1 = new ServerRequest('GET', '/'); + $request0 = new ServerRequest('GET', '/'); + $this->assertEmpty($request0->getQueryParams()); + $request1 = new ServerRequest('GET', '/?foo=bar'); $params = ['name' => 'value']; - $request2 = $request1->withQueryParams($params); $this->assertNotSame($request2, $request1); - $this->assertEmpty($request1->getQueryParams()); + $this->assertSame(['foo' => 'bar'], $request1->getQueryParams()); $this->assertSame($params, $request2->getQueryParams()); } diff --git a/tests/StreamTest.php b/tests/StreamTest.php index 74767fa..d3b3f63 100644 --- a/tests/StreamTest.php +++ b/tests/StreamTest.php @@ -1,9 +1,11 @@ -assertTrue($stream->isReadable()); $this->assertTrue($stream->isWritable()); @@ -25,28 +27,55 @@ public function testConstructorInitializesProperties() $stream->close(); } + public function testConstructorSeekWithStringContent() + { + $content = \str_repeat('Hello', 50000); + $stream = Stream::create($content); + $this->assertTrue($stream->isReadable()); + $this->assertTrue($stream->isWritable()); + $this->assertTrue($stream->isSeekable()); + $this->assertEquals('Nyholm-Psr7-Zval://', $stream->getMetadata('uri')); + $this->assertTrue(\is_array($stream->getMetadata())); + $this->assertSame(250000, $stream->getSize()); + $this->assertFalse($stream->eof()); + $this->assertSame($content, $stream->getContents()); + $stream->close(); + + $stream = Stream::create('Hello'); + $this->assertEquals('php://memory', $stream->getMetadata('uri')); + $this->assertSame('Hello', $stream->getContents()); + } + public function testStreamClosesHandleOnDestruct() { - $handle = fopen('php://temp', 'r'); + $handle = \fopen('php://temp', 'r'); $stream = Stream::create($handle); unset($stream); - $this->assertFalse(is_resource($handle)); + $this->assertFalse(\is_resource($handle)); } public function testConvertsToString() { - $handle = fopen('php://temp', 'w+'); - fwrite($handle, 'data'); + $handle = \fopen('php://temp', 'w+'); + \fwrite($handle, 'data'); $stream = Stream::create($handle); $this->assertEquals('data', (string) $stream); $this->assertEquals('data', (string) $stream); $stream->close(); } + public function testBuildFromString() + { + $stream = Stream::create('data'); + $this->assertEquals('data', $stream->getContents()); + $this->assertEquals('data', $stream->__toString()); + $stream->close(); + } + public function testGetsContents() { - $handle = fopen('php://temp', 'w+'); - fwrite($handle, 'data'); + $handle = \fopen('php://temp', 'w+'); + \fwrite($handle, 'data'); $stream = Stream::create($handle); $this->assertEquals('', $stream->getContents()); $stream->seek(0); @@ -56,8 +85,8 @@ public function testGetsContents() public function testChecksEof() { - $handle = fopen('php://temp', 'w+'); - fwrite($handle, 'data'); + $handle = \fopen('php://temp', 'w+'); + \fwrite($handle, 'data'); $stream = Stream::create($handle); $this->assertFalse($stream->eof()); $stream->read(4); @@ -67,8 +96,8 @@ public function testChecksEof() public function testGetSize() { - $size = filesize(__FILE__); - $handle = fopen(__FILE__, 'r'); + $size = \filesize(__FILE__); + $handle = \fopen(__FILE__, 'r'); $stream = Stream::create($handle); $this->assertEquals($size, $stream->getSize()); // Load from cache @@ -78,8 +107,8 @@ public function testGetSize() public function testEnsuresSizeIsConsistent() { - $h = fopen('php://temp', 'w+'); - $this->assertEquals(3, fwrite($h, 'foo')); + $h = \fopen('php://temp', 'w+'); + $this->assertEquals(3, \fwrite($h, 'foo')); $stream = Stream::create($h); $this->assertEquals(3, $stream->getSize()); $this->assertEquals(4, $stream->write('test')); @@ -90,20 +119,20 @@ public function testEnsuresSizeIsConsistent() public function testProvidesStreamPosition() { - $handle = fopen('php://temp', 'w+'); + $handle = \fopen('php://temp', 'w+'); $stream = Stream::create($handle); $this->assertEquals(0, $stream->tell()); $stream->write('foo'); $this->assertEquals(3, $stream->tell()); $stream->seek(1); $this->assertEquals(1, $stream->tell()); - $this->assertSame(ftell($handle), $stream->tell()); + $this->assertSame(\ftell($handle), $stream->tell()); $stream->close(); } public function testCanDetachStream() { - $r = fopen('php://temp', 'w+'); + $r = \fopen('php://temp', 'w+'); $stream = Stream::create($r); $stream->write('foo'); $this->assertTrue($stream->isReadable()); @@ -144,13 +173,27 @@ public function testCanDetachStream() $throws(function ($stream) { $stream->getContents(); }); - $this->assertSame('', (string) $stream); + if (\PHP_VERSION_ID >= 70400) { + $throws(function ($stream) { + (string) $stream; + }); + } elseif (!(new \ReflectionMethod(StreamInterface::class, '__toString'))->hasReturnType()) { + $this->assertSame('', (string) $stream); + + SymfonyErrorHandler::register(); + $throws(function ($stream) { + (string) $stream; + }); + \restore_error_handler(); + \restore_exception_handler(); + } + $stream->close(); } public function testCloseClearProperties() { - $handle = fopen('php://temp', 'r+'); + $handle = \fopen('php://temp', 'r+'); $stream = Stream::create($handle); $stream->close(); @@ -160,4 +203,34 @@ public function testCloseClearProperties() $this->assertNull($stream->getSize()); $this->assertEmpty($stream->getMetadata()); } + + public function testUnseekableStreamWrapper() + { + \stream_wrapper_register('nyholm-psr7-test', TestStreamWrapper::class); + $handle = \fopen('nyholm-psr7-test://', 'r'); + \stream_wrapper_unregister('nyholm-psr7-test'); + + $stream = Stream::create($handle); + $this->assertFalse($stream->isSeekable()); + } +} + +class TestStreamWrapper +{ + public $context; + + public function stream_open() + { + return true; + } + + public function stream_seek(int $offset, int $whence = \SEEK_SET) + { + return false; + } + + public function stream_eof() + { + return true; + } } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index 4d43352..8a8f9f8 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -14,21 +14,21 @@ class UploadedFileTest extends TestCase { protected $cleanup; - public function setUp() + protected function setUp(): void { $this->cleanup = []; } - public function tearDown() + protected function tearDown(): void { foreach ($this->cleanup as $file) { - if (is_scalar($file) && file_exists($file)) { - unlink($file); + if (\is_scalar($file) && \file_exists($file)) { + \unlink($file); } } } - public function invalidStreams() + public static function invalidStreams() { return [ 'null' => [null], @@ -49,10 +49,10 @@ public function testRaisesExceptionOnInvalidStreamOrFile($streamOrFile) $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid stream or file provided for UploadedFile'); - new UploadedFile($streamOrFile, 0, UPLOAD_ERR_OK); + new UploadedFile($streamOrFile, 0, \UPLOAD_ERR_OK); } - public function invalidErrorStatuses() + public static function invalidErrorStatuses() { return [ 'null' => [null], @@ -75,10 +75,10 @@ public function testRaisesExceptionOnInvalidErrorStatus($status) $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('status'); - new UploadedFile(fopen('php://temp', 'wb+'), 0, $status); + new UploadedFile(\fopen('php://temp', 'wb+'), 0, $status); } - public function invalidFilenamesAndMediaTypes() + public static function invalidFilenamesAndMediaTypes() { return [ 'true' => [true], @@ -98,7 +98,7 @@ public function testRaisesExceptionOnInvalidClientFilename($filename) $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('filename'); - new UploadedFile(fopen('php://temp', 'wb+'), 0, UPLOAD_ERR_OK, $filename); + new UploadedFile(\fopen('php://temp', 'wb+'), 0, \UPLOAD_ERR_OK, $filename); } /** @@ -109,21 +109,21 @@ public function testRaisesExceptionOnInvalidClientMediaType($mediaType) $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('media type'); - new UploadedFile(fopen('php://temp', 'wb+'), 0, UPLOAD_ERR_OK, 'foobar.baz', $mediaType); + new UploadedFile(\fopen('php://temp', 'wb+'), 0, \UPLOAD_ERR_OK, 'foobar.baz', $mediaType); } public function testGetStreamReturnsOriginalStreamObject() { $stream = Stream::create(''); - $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); $this->assertSame($stream, $upload->getStream()); } public function testGetStreamReturnsWrappedPhpStream() { - $stream = fopen('php://temp', 'wb+'); - $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $stream = \fopen('php://temp', 'wb+'); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); $uploadStream = $upload->getStream()->detach(); $this->assertSame($stream, $uploadStream); @@ -131,28 +131,28 @@ public function testGetStreamReturnsWrappedPhpStream() public function testGetStream() { - $upload = new UploadedFile(__DIR__.'/Resources/foo.txt', 0, UPLOAD_ERR_OK); + $upload = new UploadedFile(__DIR__ . '/Resources/foo.txt', 0, \UPLOAD_ERR_OK); $stream = $upload->getStream(); $this->assertInstanceOf(StreamInterface::class, $stream); - $this->assertEquals("Foobar\n", $stream->__toString()); + $this->assertEquals('Foobar' . \PHP_EOL, $stream->__toString()); } public function testSuccessful() { $stream = Stream::create('Foo bar!'); - $upload = new UploadedFile($stream, $stream->getSize(), UPLOAD_ERR_OK, 'filename.txt', 'text/plain'); + $upload = new UploadedFile($stream, $stream->getSize(), \UPLOAD_ERR_OK, 'filename.txt', 'text/plain'); $this->assertEquals($stream->getSize(), $upload->getSize()); $this->assertEquals('filename.txt', $upload->getClientFilename()); $this->assertEquals('text/plain', $upload->getClientMediaType()); - $this->cleanup[] = $to = tempnam(sys_get_temp_dir(), 'successful'); + $this->cleanup[] = $to = \tempnam(\sys_get_temp_dir(), 'successful'); $upload->moveTo($to); $this->assertFileExists($to); - $this->assertEquals($stream->__toString(), file_get_contents($to)); + $this->assertEquals($stream->__toString(), \file_get_contents($to)); } - public function invalidMovePaths() + public static function invalidMovePaths() { return [ 'null' => [null], @@ -172,7 +172,7 @@ public function invalidMovePaths() public function testMoveRaisesExceptionForInvalidPath($path) { $stream = (new \Nyholm\Psr7\Factory\Psr17Factory())->createStream('Foo bar!'); - $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); $this->cleanup[] = $path; @@ -184,11 +184,11 @@ public function testMoveRaisesExceptionForInvalidPath($path) public function testMoveCannotBeCalledMoreThanOnce() { $stream = (new \Nyholm\Psr7\Factory\Psr17Factory())->createStream('Foo bar!'); - $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); - $this->cleanup[] = $to = tempnam(sys_get_temp_dir(), 'diac'); + $this->cleanup[] = $to = \tempnam(\sys_get_temp_dir(), 'diac'); $upload->moveTo($to); - $this->assertTrue(file_exists($to)); + $this->assertTrue(\file_exists($to)); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('moved'); @@ -198,9 +198,9 @@ public function testMoveCannotBeCalledMoreThanOnce() public function testCannotRetrieveStreamAfterMove() { $stream = (new \Nyholm\Psr7\Factory\Psr17Factory())->createStream('Foo bar!'); - $upload = new UploadedFile($stream, 0, UPLOAD_ERR_OK); + $upload = new UploadedFile($stream, 0, \UPLOAD_ERR_OK); - $this->cleanup[] = $to = tempnam(sys_get_temp_dir(), 'diac'); + $this->cleanup[] = $to = \tempnam(\sys_get_temp_dir(), 'diac'); $upload->moveTo($to); $this->assertFileExists($to); @@ -209,16 +209,16 @@ public function testCannotRetrieveStreamAfterMove() $upload->getStream(); } - public function nonOkErrorStatus() + public static function nonOkErrorStatus() { 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], + '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], ]; } @@ -239,7 +239,7 @@ public function testMoveToRaisesExceptionWhenErrorStatusPresent($status) $uploadedFile = new UploadedFile('not ok', 0, $status); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('upload error'); - $uploadedFile->moveTo(__DIR__.'/'.uniqid()); + $uploadedFile->moveTo(__DIR__ . '/' . \uniqid()); } /** @@ -255,12 +255,12 @@ public function testGetStreamRaisesExceptionWhenErrorStatusPresent($status) public function testMoveToCreatesStreamIfOnlyAFilenameWasProvided() { - $this->cleanup[] = $from = tempnam(sys_get_temp_dir(), 'copy_from'); - $this->cleanup[] = $to = tempnam(sys_get_temp_dir(), 'copy_to'); + $this->cleanup[] = $from = \tempnam(\sys_get_temp_dir(), 'copy_from'); + $this->cleanup[] = $to = \tempnam(\sys_get_temp_dir(), 'copy_to'); - copy(__FILE__, $from); + \copy(__FILE__, $from); - $uploadedFile = new UploadedFile($from, 100, UPLOAD_ERR_OK, basename($from), 'text/plain'); + $uploadedFile = new UploadedFile($from, 100, \UPLOAD_ERR_OK, \basename($from), 'text/plain'); $uploadedFile->moveTo($to); $this->assertFileEquals(__FILE__, $to); diff --git a/tests/UriTest.php b/tests/UriTest.php index cb50d3a..4aed446 100644 --- a/tests/UriTest.php +++ b/tests/UriTest.php @@ -1,4 +1,4 @@ -assertSame('https://user:pass@example.com:8080/path/123?q=abc#test', (string) $uri); } + public function testSupportsUrlEncodedValues() + { + $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); + } + /** * @dataProvider getValidUris */ @@ -59,7 +81,7 @@ public function testValidUrisStayValid($input) $this->assertSame($input, (string) $uri); } - public function getValidUris() + public static function getValidUris() { return [ ['urn:path-rootless'], @@ -99,7 +121,7 @@ public function testInvalidUrisThrowException($invalidUri) new Uri($invalidUri); } - public function getInvalidUris() + public static function getInvalidUris() { return [ // parse_url() requires the host component which makes sense for http(s) @@ -113,25 +135,35 @@ public function getInvalidUris() public function testPortMustBeValid() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid port: 100000. Must be between 1 and 65535'); + $this->expectExceptionMessage('Invalid port: 100000. Must be between 0 and 65535'); (new Uri())->withPort(100000); } - public function testWithPortCannotBeZero() + public function testWithPortCannotBeNegative() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid port: 0. Must be between 1 and 65535'); + $this->expectExceptionMessage('Invalid port: -1. Must be between 0 and 65535'); - (new Uri())->withPort(0); + (new Uri())->withPort(-1); } - public function testParseUriPortCannotBeZero() + public function testParseUriPortCannotBeNegative() { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Unable to parse URI'); - new Uri('//example.com:0'); + new Uri('//example.com:-1'); + } + + public function testParseUriPortCanBeZero() + { + if (\version_compare(\PHP_VERSION, '7.4.12') < 0) { + self::markTestSkipped('Skipping this on low PHP versions.'); + } + + $uri = new Uri('//example.com:0'); + $this->assertEquals(0, $uri->getPort()); } public function testSchemeMustHaveCorrectType() @@ -358,7 +390,7 @@ public function testAuthorityWithUserInfoButWithoutHost() $this->assertSame('', $uri->getAuthority()); } - public function uriComponentsEncodingProvider() + public static function uriComponentsEncodingProvider() { $unreserved = 'a-zA-Z0-9.-_~!$&\'()*+,;=:@'; @@ -430,7 +462,7 @@ public function testAddsSlashForRelativeUriStringWithHost() // 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()); + $this->assertSame('/foo', $uri->getPath()); // concatenating a relative path with a host doesn't work: "//example.comfoo" would be wrong $this->assertSame('//example.com/foo', (string) $uri); } @@ -440,7 +472,7 @@ public function testRemoveExtraSlashesWihoutHost() // If the path is starting with more than one "/" and no authority is // present, the starting slashes MUST be reduced to one. $uri = (new Uri())->withPath('//foo'); - $this->assertSame('//foo', $uri->getPath()); + $this->assertSame('/foo', $uri->getPath()); // URI "//foo" would be interpreted as network reference and thus change the original path to the host $this->assertSame('/foo', (string) $uri); } @@ -471,4 +503,17 @@ public function testImmutability() $this->assertNotSame($uri, $uri->withQuery('q=abc')); $this->assertNotSame($uri, $uri->withFragment('test')); } + + public function testUtf8Host() + { + $uri = new Uri('http://ουτοπία.δπθ.gr/'); + $this->assertSame('ουτοπία.δπθ.gr', $uri->getHost()); + $new = $uri->withHost('程式设计.com'); + $this->assertSame('程式设计.com', $new->getHost()); + + $testDomain = 'παράδειγμα.δοκιμή'; + $uri = (new Uri())->withHost($testDomain); + $this->assertSame($testDomain, $uri->getHost()); + $this->assertSame('//' . $testDomain, (string) $uri); + } }