diff --git a/.gitattributes b/.gitattributes index 65a00ff..2abe789 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,10 +1,12 @@ # exclude dev files from export to reduce archive download size /.gitattributes export-ignore +/.github/ISSUE_TEMPLATE/ export-ignore /.github/workflows/ export-ignore /.gitignore export-ignore /docs/ export-ignore /examples/ export-ignore /mkdocs.yml export-ignore +/phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /phpunit.xml.legacy export-ignore /tests/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7ef07a..4b63994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,28 @@ jobs: - run: vendor/bin/phpunit --coverage-text --stderr -c phpunit.xml.legacy if: ${{ matrix.php < 7.3 }} + PHPStan: + name: PHPStan (PHP ${{ matrix.php }}) + runs-on: ubuntu-22.04 + strategy: + matrix: + php: + - 8.2 + - 8.1 + - 8.0 + - 7.4 + - 7.3 + - 7.2 + - 7.1 + steps: + - uses: actions/checkout@v3 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer install + - run: vendor/bin/phpstan + Built-in-webserver: name: Built-in webserver (PHP ${{ matrix.php }}) runs-on: ubuntu-22.04 diff --git a/composer.json b/composer.json index b914d81..aa590bd 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "react/promise": "^3 || ^2.7" }, "require-dev": { + "phpstan/phpstan": "1.8.10 || 1.4.10", "phpunit/phpunit": "^9.5 || ^7.5", "psr/container": "^2 || ^1" }, diff --git a/examples/index.php b/examples/index.php index 3ae7e74..5bf34f7 100644 --- a/examples/index.php +++ b/examples/index.php @@ -118,7 +118,7 @@ return new React\Http\Message\Response( React\Http\Message\Response::STATUS_OK, [ - 'Content-Length' => 5, + 'Content-Length' => '5', 'Content-Type' => 'text/plain; charset=utf-8', 'X-Is-Head' => 'true' ] @@ -161,7 +161,7 @@ React\Http\Message\Response::STATUS_NOT_MODIFIED, [ 'ETag' => $etag, - 'Content-Length' => strlen($etag) - 1 + 'Content-Length' => (string) (strlen($etag) - 1) ] ); } @@ -193,7 +193,7 @@ $app->get('/error/yield', function () { yield null; }); -$app->get('/error/class', 'Acme\Http\UnknownDeleteUserController'); +$app->get('/error/class', 'Acme\Http\UnknownDeleteUserController'); // @phpstan-ignore-line // OPTIONS * $app->options('', function () { diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..428e5d0 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,17 @@ +parameters: + level: 5 + + paths: + - examples/ + - src/ + - tests/ + + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # ignore generic usage like `PromiseInterface` until fixed upstream + - '/^PHPDoc tag @return contains generic type React\\Promise\\PromiseInterface but interface React\\Promise\\PromiseInterface is not generic\.$/' + # ignore unknown `Fiber` class (PHP 8.1+) + - '/^Instantiated class Fiber not found\.$/' + - '/^Call to method (start|isTerminated|getReturn)\(\) on an unknown class Fiber\.$/' + # ignore incomplete type information for mocks in legacy PHPUnit 7.5 + - '/^Parameter #\d+ \$.+ of class .+ constructor expects .+, PHPUnit\\Framework\\MockObject\\MockObject given\.$/' diff --git a/src/App.php b/src/App.php index cd4b16c..30d93ad 100644 --- a/src/App.php +++ b/src/App.php @@ -286,7 +286,7 @@ private function runLoop() } while (true); // remove signal handlers when loop stops (if registered) - Loop::removeSignal(\defined('SIGINT') ? \SIGINT : 2, $f1); + Loop::removeSignal(\defined('SIGINT') ? \SIGINT : 2, $f1 ?? 'printf'); Loop::removeSignal(\defined('SIGTERM') ? \SIGTERM : 15, $f2 ?? 'printf'); } @@ -309,11 +309,12 @@ private function runOnce() /** * @param ServerRequestInterface $request - * @return ResponseInterface|PromiseInterface + * @return ResponseInterface|PromiseInterface * Returns a response or a Promise which eventually fulfills with a * response. This method never throws or resolves a rejected promise. * If the request can not be routed or the handler fails, it will be * turned into a valid error response before returning. + * @throws void */ private function handleRequest(ServerRequestInterface $request) { diff --git a/src/Container.php b/src/Container.php index 7c4521b..def505b 100644 --- a/src/Container.php +++ b/src/Container.php @@ -13,9 +13,10 @@ class Container /** @var array|ContainerInterface */ private $container; - /** @var array|ContainerInterface $loader */ + /** @param array|ContainerInterface $loader */ public function __construct($loader = []) { + /** @var mixed $loader explicit type check for mixed if user ignores parameter type */ if (!\is_array($loader) && !$loader instanceof ContainerInterface) { throw new \TypeError( 'Argument #1 ($loader) must be of type array|Psr\Container\ContainerInterface, ' . (\is_object($loader) ? get_class($loader) : gettype($loader)) . ' given' @@ -233,6 +234,7 @@ private function loadParameter(\ReflectionParameter $parameter, int $depth, bool $hasDefault = $parameter->isDefaultValueAvailable() || ((!$type instanceof \ReflectionNamedType || $type->getName() !== 'mixed') && $parameter->allowsNull()); // abort for union types (PHP 8.0+) and intersection types (PHP 8.1+) + // @phpstan-ignore-next-line for PHP < 8 if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { // @codeCoverageIgnoreStart if ($hasDefault) { return $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; diff --git a/src/ErrorHandler.php b/src/ErrorHandler.php index b70f53c..e2feebe 100644 --- a/src/ErrorHandler.php +++ b/src/ErrorHandler.php @@ -22,7 +22,7 @@ public function __construct() } /** - * @return ResponseInterface|PromiseInterface|\Generator + * @return ResponseInterface|PromiseInterface|\Generator * Returns a response, a Promise which eventually fulfills with a * response or a Generator which eventually returns a response. This * method never throws or resolves a rejected promise. If the next diff --git a/src/Io/FiberHandler.php b/src/Io/FiberHandler.php index a9deff0..e23107d 100644 --- a/src/Io/FiberHandler.php +++ b/src/Io/FiberHandler.php @@ -21,7 +21,7 @@ class FiberHandler { /** - * @return ResponseInterface|PromiseInterface|\Generator + * @return ResponseInterface|PromiseInterface|\Generator * Returns a `ResponseInterface` from the next request handler in the * chain. If the next request handler returns immediately, this method * will return immediately. If the next request handler suspends the @@ -42,6 +42,9 @@ public function __invoke(ServerRequestInterface $request, callable $next): mixed $response = $next($request); assert($response instanceof ResponseInterface || $response instanceof PromiseInterface || $response instanceof \Generator); + // if the next request handler returns immediately, the fiber can terminate immediately without using a Deferred + // if the next request handler suspends the fiber, we only reach this point after resuming the fiber, so the code below will have assigned a Deferred + /** @var ?Deferred $deferred */ if ($deferred !== null) { $deferred->resolve($response); } @@ -49,8 +52,10 @@ public function __invoke(ServerRequestInterface $request, callable $next): mixed return $response; }); + /** @throws void because the next handler will always be an `ErrorHandler` */ $fiber->start(); if ($fiber->isTerminated()) { + /** @throws void because fiber is known to have terminated successfully */ return $fiber->getReturn(); } diff --git a/src/Io/RouteHandler.php b/src/Io/RouteHandler.php index 5757d05..2d637e3 100644 --- a/src/Io/RouteHandler.php +++ b/src/Io/RouteHandler.php @@ -76,7 +76,7 @@ public function map(array $methods, string $route, $handler, ...$handlers): void public function __invoke(ServerRequestInterface $request) { if ($request->getRequestTarget()[0] !== '/' && $request->getRequestTarget() !== '*') { - return $this->errorHandler->requestProxyUnsupported($request); + return $this->errorHandler->requestProxyUnsupported(); } if ($this->routeDispatcher === null) { @@ -84,20 +84,29 @@ public function __invoke(ServerRequestInterface $request) } $routeInfo = $this->routeDispatcher->dispatch($request->getMethod(), $request->getUri()->getPath()); - switch ($routeInfo[0]) { - case \FastRoute\Dispatcher::NOT_FOUND: - return $this->errorHandler->requestNotFound($request); - case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED: - return $this->errorHandler->requestMethodNotAllowed($routeInfo[1]); - case \FastRoute\Dispatcher::FOUND: - $handler = $routeInfo[1]; - $vars = $routeInfo[2]; - - foreach ($vars as $key => $value) { - $request = $request->withAttribute($key, rawurldecode($value)); - } - - return $handler($request); + assert(\is_array($routeInfo) && isset($routeInfo[0])); + + // happy path: matching route found, assign route attributes and invoke request handler + if ($routeInfo[0] === \FastRoute\Dispatcher::FOUND) { + $handler = $routeInfo[1]; + $vars = $routeInfo[2]; + + foreach ($vars as $key => $value) { + $request = $request->withAttribute($key, rawurldecode($value)); + } + + return $handler($request); } - } // @codeCoverageIgnore + + // no matching route found: report error `404 Not Found` + if ($routeInfo[0] === \FastRoute\Dispatcher::NOT_FOUND) { + return $this->errorHandler->requestNotFound(); + } + + // unexpected request method for route: report error `405 Method Not Allowed` + assert($routeInfo[0] === \FastRoute\Dispatcher::METHOD_NOT_ALLOWED); + assert(\is_array($routeInfo[1]) && \count($routeInfo[1]) > 0); + + return $this->errorHandler->requestMethodNotAllowed($routeInfo[1]); + } } diff --git a/src/Io/SapiHandler.php b/src/Io/SapiHandler.php index e3baeab..37218dd 100644 --- a/src/Io/SapiHandler.php +++ b/src/Io/SapiHandler.php @@ -46,7 +46,7 @@ public function requestFromGlobals(): ServerRequestInterface $target = ($_SERVER['REQUEST_URI'] ?? '/'); $url = $target; if (($target[0] ?? '/') === '/' || $target === '*') { - $url = ($_SERVER['HTTPS'] ?? null === 'on' ? 'https://' : 'http://') . ($host ?? 'localhost') . ($target === '*' ? '' : $target); + $url = (($_SERVER['HTTPS'] ?? null) === 'on' ? 'https://' : 'http://') . ($host ?? 'localhost') . ($target === '*' ? '' : $target); } $body = file_get_contents('php://input'); diff --git a/tests/AccessLogHandlerTest.php b/tests/AccessLogHandlerTest.php index d4037ea..db38244 100644 --- a/tests/AccessLogHandlerTest.php +++ b/tests/AccessLogHandlerTest.php @@ -143,7 +143,7 @@ public function testInvokeWithCoroutineNextPrintsRequestLogWithCurrentDateAndTim $response = new Response(200, [], "Hello\n"); $generator = $handler($request, function () use ($response) { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return $response; diff --git a/tests/AppTest.php b/tests/AppTest.php index 468f041..24c9947 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -1230,7 +1230,7 @@ public function testHandleRequestWithMatchingRouteReturnsResponseWhenHandlerRetu $app = $this->createAppWithoutLogger(); $app->get('/users', function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } @@ -1611,7 +1611,7 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp $line = __LINE__ + 5; $app->get('/users', function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } throw new \RuntimeException('Foo'); @@ -1837,7 +1837,7 @@ public function testHandleRequestWithMatchingRouteReturnsInternalServerErrorResp { $app = $this->createAppWithoutLogger(); - $app->get('/users', 'UnknownClass'); + $app->get('/users', 'UnknownClass'); // @phpstan-ignore-line $request = new ServerRequest('GET', 'http://localhost/users'); diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php index 5c5ea8b..0c09c6d 100644 --- a/tests/ContainerTest.php +++ b/tests/ContainerTest.php @@ -134,10 +134,11 @@ public function testCallableReturnsCallableForUnionWithNullViaAutowiringWillDefa { $request = new ServerRequest('GET', 'http://example.com/'); + // @phpstan-ignore-next-line for PHP < 8 $controller = new class(null) { private $data = false; - #[PHP8] public function __construct(string|int|null $data) { $this->data = $data; } + #[PHP8] public function __construct(string|int|null $data) { $this->data = $data; } // @phpstan-ignore-line public function __invoke(ServerRequestInterface $request) { @@ -223,10 +224,11 @@ public function testCallableReturnsCallableForUnionWithIntDefaultValueViaAutowir { $request = new ServerRequest('GET', 'http://example.com/'); + // @phpstan-ignore-next-line for PHP < 8 $controller = new class(null) { private $data = false; - #[PHP8] public function __construct(string|int|null $data = 42) { $this->data = $data; } + #[PHP8] public function __construct(string|int|null $data = 42) { $this->data = $data; } // @phpstan-ignore-line public function __invoke(ServerRequestInterface $request) { @@ -281,10 +283,11 @@ public function testCallableReturnsCallableForMixedWithStringDefaultViaAutowirin { $request = new ServerRequest('GET', 'http://example.com/'); + // @phpstan-ignore-next-line for PHP < 8 $controller = new class(null) { private $data = false; - #[PHP8] public function __construct(mixed $data = 'empty') { $this->data = $data; } + #[PHP8] public function __construct(mixed $data = 'empty') { $this->data = $data; } // @phpstan-ignore-line public function __invoke(ServerRequestInterface $request) { @@ -773,7 +776,8 @@ public function __invoke() } }; - $fn = #[PHP8] fn(mixed $data = 42) => new Response(200, [], json_encode($data)); + $fn = null; + $fn = #[PHP8] fn(mixed $data = 42) => new Response(200, [], json_encode($data)); // @phpstan-ignore-line $container = new Container([ ResponseInterface::class => $fn, 'data' => null @@ -844,7 +848,7 @@ public function __invoke() ResponseInterface::class => function (?\stdClass $user, ?\stdClass $data) { return new Response(200, [], json_encode(['user' => $user, 'data' => $data])); }, - 'user' => function (): ?\stdClass { + 'user' => function (): ?\stdClass { // @phpstan-ignore-line return (object) []; } ]); @@ -1717,7 +1721,7 @@ public function testCtorThrowsWhenMapContainsInvalidArray() $this->expectException(\BadMethodCallException::class); $this->expectExceptionMessage('Map for all contains unexpected array'); - new Container([ + new Container([ // @phpstan-ignore-line 'all' => [] ]); } @@ -1944,7 +1948,7 @@ public function testCallableReturnsCallableThatThrowsWhenFactoryReturnsInvalidCl $container = new Container($psr); - $callable = $container->callable('FooBar'); + $callable = $container->callable('FooBar'); // @phpstan-ignore-line $this->expectException(\BadMethodCallException::class); $this->expectExceptionMessage('Request handler class FooBar failed to load: Unable to load class'); @@ -2175,6 +2179,6 @@ public function testCtorWithInvalidValueThrows() { $this->expectException(\TypeError::class); $this->expectExceptionMessage('Argument #1 ($loader) must be of type array|Psr\Container\ContainerInterface, stdClass given'); - new Container((object) []); + new Container((object) []); // @phpstan-ignore-line } } diff --git a/tests/ErrorHandlerTest.php b/tests/ErrorHandlerTest.php index a90c366..d565a52 100644 --- a/tests/ErrorHandlerTest.php +++ b/tests/ErrorHandlerTest.php @@ -53,7 +53,7 @@ public function testInvokeWithHandlerReturningGeneratorReturningResponseReturnsG $response = new Response(); $generator = $handler($request, function () use ($response) { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return $response; @@ -166,7 +166,7 @@ public function testInvokeWithHandlerReturningGeneratorThrowingExceptionReturnsG $request = new ServerRequest('GET', 'http://example.com/'); $generator = $handler($request, function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } throw new \RuntimeException(); @@ -324,7 +324,7 @@ public function testInvokeWithHandlerReturningGeneratorReturningNullReturnsGener $request = new ServerRequest('GET', 'http://example.com/'); $generator = $handler($request, function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return null; diff --git a/tests/Fixtures/InvalidConstructorInt.php b/tests/Fixtures/InvalidConstructorInt.php index d5b62c2..d8d2f2f 100644 --- a/tests/Fixtures/InvalidConstructorInt.php +++ b/tests/Fixtures/InvalidConstructorInt.php @@ -6,5 +6,6 @@ class InvalidConstructorInt { public function __construct(int $value) { + assert(is_int($value)); } } diff --git a/tests/Fixtures/InvalidConstructorIntersection.php b/tests/Fixtures/InvalidConstructorIntersection.php index ddbd84a..7431cbd 100644 --- a/tests/Fixtures/InvalidConstructorIntersection.php +++ b/tests/Fixtures/InvalidConstructorIntersection.php @@ -5,7 +5,6 @@ /** PHP 8.1+ **/ class InvalidConstructorIntersection { - public function __construct(\Traversable&\ArrayAccess $value) - { - } + // @phpstan-ignore-next-line for PHP < 8 + #[PHP8] public function __construct(\Traversable&\ArrayAccess $value) { assert($value instanceof \Traversable && $value instanceof \ArrayAccess); } } diff --git a/tests/Fixtures/InvalidConstructorSelf.php b/tests/Fixtures/InvalidConstructorSelf.php index 74d81d1..a242d44 100644 --- a/tests/Fixtures/InvalidConstructorSelf.php +++ b/tests/Fixtures/InvalidConstructorSelf.php @@ -6,5 +6,6 @@ class InvalidConstructorSelf { public function __construct(InvalidConstructorSelf $value) { + assert($value instanceof self); } } diff --git a/tests/Fixtures/InvalidConstructorUnion.php b/tests/Fixtures/InvalidConstructorUnion.php index ea96ce1..49d0c08 100644 --- a/tests/Fixtures/InvalidConstructorUnion.php +++ b/tests/Fixtures/InvalidConstructorUnion.php @@ -5,7 +5,6 @@ /** PHP 8.0+ **/ class InvalidConstructorUnion { - public function __construct(int|float $value) - { - } + // @phpstan-ignore-next-line for PHP < 8 + #[PHP8] public function __construct(int|float $value) { assert(is_int($value) || is_float($value)); } } diff --git a/tests/Fixtures/InvalidConstructorUnknown.php b/tests/Fixtures/InvalidConstructorUnknown.php index 47005b2..a89a577 100644 --- a/tests/Fixtures/InvalidConstructorUnknown.php +++ b/tests/Fixtures/InvalidConstructorUnknown.php @@ -4,7 +4,7 @@ class InvalidConstructorUnknown { - public function __construct(\UnknownClass $value) + public function __construct(\UnknownClass $value) // @phpstan-ignore-line { } } diff --git a/tests/Fixtures/InvalidConstructorUntyped.php b/tests/Fixtures/InvalidConstructorUntyped.php index f0d10ac..ae95d17 100644 --- a/tests/Fixtures/InvalidConstructorUntyped.php +++ b/tests/Fixtures/InvalidConstructorUntyped.php @@ -4,7 +4,9 @@ class InvalidConstructorUntyped { + /** @param mixed $value */ public function __construct($value) { + assert($value === $value); } } diff --git a/tests/Io/FiberHandlerTest.php b/tests/Io/FiberHandlerTest.php index 26837a2..ac1e781 100644 --- a/tests/Io/FiberHandlerTest.php +++ b/tests/Io/FiberHandlerTest.php @@ -61,7 +61,7 @@ public function testInvokeWithHandlerReturningGeneratorReturningResponseReturnsG $response = new Response(); $generator = $handler($request, function () use ($response) { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return $response; diff --git a/tests/Io/RouteHandlerTest.php b/tests/Io/RouteHandlerTest.php index ae5f0fa..b38a81b 100644 --- a/tests/Io/RouteHandlerTest.php +++ b/tests/Io/RouteHandlerTest.php @@ -200,6 +200,7 @@ public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandle $controller = new class { public static $response; public function __construct(int $value = null) { + assert($value === null); } public function __invoke() { return self::$response; @@ -223,6 +224,7 @@ public function testHandleRequestWithGetRequestReturnsResponseFromMatchingHandle $controller = new class(null) { public static $response; public function __construct(?int $value) { + assert($value === null); } public function __invoke() { return self::$response; diff --git a/tests/Io/SapiHandlerTest.php b/tests/Io/SapiHandlerTest.php index 277b95d..17758a9 100644 --- a/tests/Io/SapiHandlerTest.php +++ b/tests/Io/SapiHandlerTest.php @@ -216,7 +216,7 @@ public function testSendResponseSendsEmptyBodyWithGivenHeadersButWithoutExplicit $_SERVER['SERVER_PROTOCOL'] = 'http/1.1'; $sapi = new SapiHandler(); - $response = new Response(204, ['Content-Type' => 'application/json', 'Content-Length' => 2], '{}'); + $response = new Response(204, ['Content-Type' => 'application/json', 'Content-Length' => '2'], '{}'); $this->expectOutputString(''); $sapi->sendResponse($response);