diff --git a/.gitattributes b/.gitattributes index b8ea8fd..974f175 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,8 +9,8 @@ /.coveralls.yml export-ignore /.editorconfig export-ignore /.gitattributes export-ignore +/.github export-ignore /.gitignore export-ignore -/.travis.yml export-ignore /CODE_OF_CONDUCT.md export-ignore /CONTRIBUTING.md export-ignore /README.md export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9e4c2ee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 10 + versioning-strategy: increase diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..63bf993 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,51 @@ +name: Tests + +on: [push, pull_request] + +jobs: + tests: + name: Tests PHP ${{ matrix.php }} + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + php: [7.3, 7.4, 8.0] + experimental: [false] + include: + - php: 8.0 + analysis: true + - php: 8.1 + experimental: true + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Install dependencies with Composer + uses: ramsey/composer-install@v1 + + - name: Coding standards + if: matrix.analysis + run: vendor/bin/phpcs + + - name: Static analysis + if: matrix.analysis + run: vendor/bin/phpstan + + - name: Tests + run: vendor/bin/phpunit --coverage-clover clover.xml + + - name: Upload coverage results to Coveralls + if: matrix.analysis + env: + COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + composer require php-coveralls/php-coveralls -n -W + vendor/bin/php-coveralls --coverage_clover=clover.xml -v diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a74f58d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: php - -matrix: - include: - - php: 7.2 - - php: 7.3 - - php: 7.4 - env: ANALYSIS='true' - - php: nightly - allow_failures: - - php: nightly - -before_script: - - if [[ "$ANALYSIS" == 'true' ]]; then composer require php-coveralls/php-coveralls:^2.2.0 ; fi - - composer install -n - -script: - - if [[ "$ANALYSIS" != 'true' ]]; then vendor/bin/phpunit ; fi - - if [[ "$ANALYSIS" == 'true' ]]; then vendor/bin/phpunit --coverage-clover clover.xml ; fi - - if [[ "$ANALYSIS" == 'true' ]]; then vendor/bin/phpcs ; fi - - if [[ "$ANALYSIS" == 'true' ]]; then vendor/bin/phpstan analyse src; fi - -after_success: - - if [[ "$ANALYSIS" == 'true' ]]; then vendor/bin/php-coveralls --coverage_clover=clover.xml -v ; fi diff --git a/README.md b/README.md index 0b32674..3c2a5f0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Strict PSR-7 implementation used by the [Slim Framework](http://www.slimframework.com/), but you may use it separately with any framework compatible with the [PSR-7 standard](https://www.php-fig.org/psr/psr-7/). -[![Build Status](https://travis-ci.org/slimphp/Slim-Psr7.svg?branch=master)](https://travis-ci.org/slimphp/Slim-Psr7) +[![Build Status](https://github.com/slimphp/Slim-Psr7/workflows/Tests/badge.svg?branch=master)](https://github.com/slimphp/Slim-Psr7/actions?query=branch:master) [![Coverage Status](https://coveralls.io/repos/github/slimphp/Slim-Psr7/badge.svg?branch=master)](https://coveralls.io/github/slimphp/Slim-Psr7?branch=master) [![Total Downloads](https://poser.pugx.org/slim/psr7/downloads)](https://packagist.org/packages/slim/psr7) [![License](https://poser.pugx.org/slim/psr7/license)](https://packagist.org/packages/slim/psr7) @@ -18,7 +18,7 @@ $ composer require slim/psr7 ``` This will install the `slim/psr7` component and all required dependencies. -PHP 7.1 or newer is required. +PHP 7.3 or newer is required. ## Tests diff --git a/composer.json b/composer.json index 50051ff..816775f 100644 --- a/composer.json +++ b/composer.json @@ -28,22 +28,23 @@ } ], "require": { - "php": "^7.2 || ^8.0", - "fig/http-message-util": "^1.1.4", + "php": "^7.3 || ^8.0", + "fig/http-message-util": "^1.1.5", "psr/http-factory": "^1.0", "psr/http-message": "^1.0", - "ralouphie/getallheaders": "^3", - "symfony/polyfill-php80": "^1.18" + "ralouphie/getallheaders": "^3.0", + "symfony/polyfill-php80": "^1.23" }, "require-dev": { "ext-json": "*", "adriansuter/php-autoload-override": "^1.2", - "http-interop/http-factory-tests": "^0.7.0", + "http-interop/http-factory-tests": "^0.9.0", "php-http/psr7-integration-tests": "dev-master", - "phpstan/phpstan": "^0.12", - "phpunit/phpunit": "^8.5 || ^9.3", - "squizlabs/php_codesniffer": "^3.5", - "weirdan/prophecy-shim": "^1.0 || ^2.0.2" + "phpspec/prophecy": "^1.14", + "phpspec/prophecy-phpunit": "^2.0", + "phpstan/phpstan": "^0.12.99", + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "^3.6" }, "provide": { "psr/http-message-implementation": "1.0", @@ -67,7 +68,7 @@ ], "phpunit": "phpunit", "phpcs": "phpcs", - "phpstan": "phpstan analyse src --memory-limit=-1" + "phpstan": "phpstan --memory-limit=-1" }, "config": { "sort-packages": true diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4ae0e40..ccac1b2 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,3 +1,5 @@ parameters: level: max checkMissingIterableValueType: false + paths: + - src diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f3bfd90..a65dc57 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,38 +1,23 @@ - - ./tests/ + tests - - - src/ - - - - - - + + + src + + + + + diff --git a/src/Cookies.php b/src/Cookies.php index db8174f..894bdb1 100644 --- a/src/Cookies.php +++ b/src/Cookies.php @@ -170,7 +170,10 @@ protected function toHeader(string $name, array $properties): string $result .= '; HttpOnly'; } - if (isset($properties['samesite']) && in_array(strtolower($properties['samesite']), ['lax', 'strict'], true)) { + if ( + isset($properties['samesite']) + && in_array(strtolower($properties['samesite']), ['lax', 'strict', 'none'], true) + ) { // While strtolower is needed for correct comparison, the RFC doesn't care about case $result .= '; SameSite=' . $properties['samesite']; } diff --git a/src/Factory/StreamFactory.php b/src/Factory/StreamFactory.php index caeab21..31d99b5 100644 --- a/src/Factory/StreamFactory.php +++ b/src/Factory/StreamFactory.php @@ -23,7 +23,6 @@ use function restore_error_handler; use function rewind; use function set_error_handler; -use function sprintf; class StreamFactory implements StreamFactoryInterface { @@ -54,40 +53,21 @@ public function createStreamFromFile( string $mode = 'r', StreamInterface $cache = null ): StreamInterface { - // When fopen fails, PHP 7 normally raises a warning. Add an error - // handler to check for errors and throw an exception instead. - // On PHP 8, exceptions are thrown. - $exc = null; - - // Would not be initialized if fopen throws on PHP >= 8.0 - $resource = null; - - $errorHandler = function (string $errorMessage) use ($filename, $mode, &$exc) { - $exc = new RuntimeException(sprintf( - 'Unable to open %s using mode %s: %s', - $filename, - $mode, - $errorMessage - )); - }; - - set_error_handler(function (int $errno, string $errstr) use ($errorHandler) { - $errorHandler($errstr); - }); + set_error_handler( + static function (int $errno, string $errstr) use ($filename, $mode): void { + throw new RuntimeException( + "Unable to open $filename using mode $mode: $errstr", + $errno + ); + } + ); try { $resource = fopen($filename, $mode); - // @codeCoverageIgnoreStart - // (Can only be executed in PHP >= 8.0) } catch (ValueError $exception) { - $errorHandler($exception->getMessage()); - } - // @codeCoverageIgnoreEnd - restore_error_handler(); - - if ($exc) { - /** @var RuntimeException $exc */ - throw $exc; + throw new RuntimeException("Unable to open $filename using mode $mode: " . $exception->getMessage()); + } finally { + restore_error_handler(); } if (!is_resource($resource)) { diff --git a/src/Headers.php b/src/Headers.php index d90f893..afde205 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -179,7 +179,15 @@ protected function normalizeHeaderName(string $name, bool $preserveCase = false) */ protected function parseAuthorizationHeader(array $headers): array { - if (!isset($headers['Authorization'])) { + $hasAuthorizationHeader = false; + foreach ($headers as $name => $value) { + if (strtolower($name) === 'authorization') { + $hasAuthorizationHeader = true; + break; + } + } + + if (!$hasAuthorizationHeader) { if (isset($this->globals['REDIRECT_HTTP_AUTHORIZATION'])) { $headers['Authorization'] = $this->globals['REDIRECT_HTTP_AUTHORIZATION']; } elseif (isset($this->globals['PHP_AUTH_USER'])) { diff --git a/src/Uri.php b/src/Uri.php index cc3ce45..a22f38e 100644 --- a/src/Uri.php +++ b/src/Uri.php @@ -30,6 +30,12 @@ class Uri implements UriInterface { + public const SUPPORTED_SCHEMES = [ + '' => null, + 'http' => 80, + 'https' => 443 + ]; + /** * Uri scheme (without "://" suffix) * @@ -134,7 +140,7 @@ public function withScheme($scheme) * @return string * * @throws InvalidArgumentException If the Uri scheme is not a string. - * @throws InvalidArgumentException If Uri scheme is not "", "https", or "http". + * @throws InvalidArgumentException If Uri scheme is not exists in SUPPORTED_SCHEMES */ protected function filterScheme($scheme): string { @@ -142,15 +148,11 @@ protected function filterScheme($scheme): string throw new InvalidArgumentException('Uri scheme must be a string.'); } - static $valid = [ - '' => true, - 'https' => true, - 'http' => true, - ]; - $scheme = str_replace('://', '', strtolower($scheme)); - if (!isset($valid[$scheme])) { - throw new InvalidArgumentException('Uri scheme must be one of: "", "https", "http"'); + if (!key_exists($scheme, static::SUPPORTED_SCHEMES)) { + throw new InvalidArgumentException( + 'Uri scheme must be one of: "' . implode('", "', array_keys(static::SUPPORTED_SCHEMES)) . '"' + ); } return $scheme; @@ -300,7 +302,7 @@ public function withPort($port) */ protected function hasStandardPort(): bool { - return ($this->scheme === 'http' && $this->port === 80) || ($this->scheme === 'https' && $this->port === 443); + return static::SUPPORTED_SCHEMES[$this->scheme] === $this->port; } /** @@ -482,7 +484,20 @@ public function __toString(): string $query = $this->getQuery(); $fragment = $this->getFragment(); - $path = '/' . ltrim($path, '/'); + if ($path !== '') { + if ($path[0] !== '/') { + if ($authority !== '') { + // 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 ($authority === '') { + // If the path is starting with more than one "/" and no authority is present, + // the starting slashes MUST be reduced to one. + $path = '/' . ltrim($path, '/'); + } + } + } return ($scheme !== '' ? $scheme . ':' : '') . ($authority !== '' ? '//' . $authority : '') diff --git a/tests/Assets/PhpFactoryFunctionOverrides.php b/tests/Assets/PhpFactoryFunctionOverrides.php deleted file mode 100644 index 1bef58d..0000000 --- a/tests/Assets/PhpFactoryFunctionOverrides.php +++ /dev/null @@ -1,45 +0,0 @@ - $string, - 'replace' => $replace, - 'status_code' => $statusCode, - ] - ); -} - -/** - * Remove a previously emmited header from the HeaderStack. - * - * @param string|null $name - */ -function header_remove($name = null) -{ - HeaderStack::remove($name); -} - -/** - * @param string $filename - * - * @return bool - */ -function is_uploaded_file(string $filename): bool -{ - if (isset($GLOBALS['is_uploaded_file_return'])) { - return $GLOBALS['is_uploaded_file_return']; - } - - return \is_uploaded_file($filename); -} - -/** - * Return the level of the output buffering shifted by the value of the global - * variable $GLOBALS['ob_get_level_shift'] if it exists. Otherwise the function - * override calls the default php built-in function. - * - * @return int - */ -function ob_get_level(): int -{ - if (isset($GLOBALS['ob_get_level_shift'])) { - return \ob_get_level() + $GLOBALS['ob_get_level_shift']; - } - - return \ob_get_level(); -} - -/** - * @param string $source - * @param string $destination - * @param resource|null $context - * - * @return bool - */ -function copy(string $source, string $destination, $context = null): bool -{ - if (isset($GLOBALS['copy_return'])) { - return $GLOBALS['copy_return']; - } - - if ($context === null) { - return \copy($source, $destination); - } - return \copy($source, $destination, $context); -} - -/** - * @param string $oldName - * @param string $newName - * @param resource|null $context - * - * @return bool - */ -function rename(string $oldName, string $newName, $context = null): bool -{ - if (isset($GLOBALS['rename_return'])) { - return $GLOBALS['rename_return']; - } - - if ($context === null) { - return \rename($oldName, $newName); - } - return \rename($oldName, $newName, $context = null); -} diff --git a/tests/CookiesTest.php b/tests/CookiesTest.php index 0c1d84a..a5bdac6 100644 --- a/tests/CookiesTest.php +++ b/tests/CookiesTest.php @@ -275,4 +275,11 @@ public function testParseHeaderException() Cookies::parseHeader(new stdClass()); } + + public function testSetSameSiteNoneToHeaders() + { + $cookies = new Cookies(); + $cookies->set('foo', ['value' => 'bar', 'samesite' => 'None']); + $this->assertEquals('foo=bar; SameSite=None', $cookies->toHeaders()[0]); + } } diff --git a/tests/UploadedFileTest.php b/tests/UploadedFileTest.php index 4a4ffaf..c9dce30 100644 --- a/tests/UploadedFileTest.php +++ b/tests/UploadedFileTest.php @@ -12,6 +12,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\Version; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\StreamInterface; use Psr\Http\Message\UploadedFileInterface; @@ -36,6 +37,7 @@ use function sys_get_temp_dir; use function uniqid; use function unlink; +use function version_compare; use const DIRECTORY_SEPARATOR; use const UPLOAD_ERR_CANT_WRITE; @@ -358,7 +360,11 @@ public function testMoveToStream() $movedFileContents = ob_get_clean(); $this->assertEquals($contents, $movedFileContents); - $this->assertFileNotExists($fileName); + if (version_compare(Version::series(), '9.1', '>=')) { + $this->assertFileDoesNotExist($fileName); + } else { + $this->assertFileNotExists($fileName); + } } /** diff --git a/tests/UriTest.php b/tests/UriTest.php index 470708b..e6e95dd 100644 --- a/tests/UriTest.php +++ b/tests/UriTest.php @@ -31,6 +31,18 @@ public function uriFactory() return new Uri($scheme, $host, $port, $path, $query, $fragment, $user, $password); } + public function testSupportOtherSchemes() + { + $wsUri = new class ('ws', 'example.com') extends Uri { + public const SUPPORTED_SCHEMES = [ + 'ws' => 80, + 'wss' => 443, + ]; + }; + + $this->assertEquals('ws', $wsUri->getScheme()); + } + public function testGetScheme() { $this->assertEquals('https', $this->uriFactory()->getScheme()); @@ -62,7 +74,7 @@ public function testWithSchemeEmpty() public function testWithSchemeInvalid() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Uri scheme must be one of: "", "https", "http"'); + $this->expectExceptionMessageMatches('/^Uri scheme must be one of:.*$/'); $this->uriFactory()->withScheme('ftp'); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 88d5fb4..dbe4a90 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -18,9 +18,6 @@ $classLoader = require __DIR__ . '/../vendor/autoload.php'; -//require __DIR__ . '/Assets/PhpFunctionOverrides.php'; -//require __DIR__ . '/Assets/PhpFactoryFunctionOverrides.php'; - Override::apply($classLoader, [ Headers::class => [ 'getallheaders' => function () {