diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8199389 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: Bug +assignees: '' + +--- + +**Before you submit the bug** +If you're having issues with your templates or output showing up out of order, please make sure to check the [updates to output buffering](https://docs.flightphp.com/learn/migrating-to-v3#output-buffering-behavior-3-5-0) for workarounds and corrections. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Here's some sample code ... +2. Here's the URL I hit ... +3. etc +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment (please complete the following information):** + - OS: [e.g. Linux, Mac, Windows] + - Browser [e.g. chrome, safari] + - PHP Version [e.g. 7.4] + - Flight Version [e.g. 3.7.2] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index 9bed37c..7f0b216 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -.idea +.idea/ +.vscode/ vendor/ composer.phar composer.lock @@ -6,3 +7,5 @@ composer.lock coverage/ *.sublime* clover.xml +phpcs.xml +.runway-config.json \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index fcf56e7..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "php.suggest.basic": false, - "editor.detectIndentation": false, - "editor.insertSpaces": true -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4b1af6d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,58 @@ +## Contributing to the Flight Framework + +Thanks for being willing to contribute to the Flight! The goal of Flight is to keep the implementation of things simple and free of outside dependencies. +You should only bring in the depedencies you want in your project right? Right. + +### Overarching Guidelines + +Flight aims to be simple and fast. Anything that compromises either of those two things will be heavily scrutinized and/or rejected. Other things to consider when making a contribution: + +* **Dependencies** - We strive to be dependency free in Flight. Yes even polyfills, yes even `Interface` only repos like `psr/container`. The fewer dependencies, the fewer your exposed attack vectors. + +* **Coding Standards** - We use PSR1 coding standards enforced by PHPCS. Some standards that either need additional configuration or need to be manually done are: + * PHPStan is at level 6. + * `===` instead of truthy or falsey statements like `==` or `!is_array()`. + +* **PHP 7.4 Focused** - We do not make PHP 8+ focused enhancements on the framework as the focus is maintaining PHP 7.4. + +* **Core functionality vs Plugin** - Have a conversation with us in the [chatroom](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host) to know if your idea is worth makes sense in the framework or in a plugin. + +* **Testing** - Until automated testing is put into place, any PRs must pass unit testing in PHP 7.4 and PHP 8.2+. Additionally you need to run `composer test-server` and `composer test-server-v2` and ensure all the header links work correctly. + +#### **Did you find a bug?** + +* **Do not open up a GitHub issue if the bug is a security vulnerability**. Instead contact maintainers directly via email to safely pass in the information related to the security vuln. + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/flightphp/core/issues). + +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/flightphp/core/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. + +#### **Did you write a patch that fixes a bug?** + +* Open a new GitHub pull request with the patch. + +* Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. + +#### **Did you fix whitespace, format code, or make a purely cosmetic patch?** + +Changes that are cosmetic in nature and do not add anything substantial to the stability, functionality, or testability of Flight will generally not be accepted. + +#### **Do you intend to add a new feature or change an existing one?** + +* Hop into the [chatroom](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host) for Flight and let's have a conversation about the feature you want to add. It could be amazing, or it might make more sense as an extension/plugin. If you create a PR without having a conversation with maintainers, it likely will be closed without review. + +* Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. + +#### **Do you have questions about the source code?** + +* Ask any question about how to use Flight in the in the [Flight Matrix chat room](https://matrix.to/#/!cTfwPXhpkTXPXwVmxY:matrix.org?via=matrix.org&via=leitstelle511.net&via=integrations.ems.host). + +#### **Do you want to contribute to the Flight documentation?** + +* Please see the [Flight Documentation repo on GitHub](https://github.com/flightphp/docs). + +Flight is a volunteer effort. We encourage you to pitch in and join! + +Thanks! :heart: :heart: :heart: + +Flight Team diff --git a/README.md b/README.md index 4961468..a749fdb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ -![PHPStan: enabled](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) -![PHPStan: level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) +[![Version](http://poser.pugx.org/flightphp/core/version)](https://packagist.org/packages/flightphp/core) +[![Monthly Downloads](http://poser.pugx.org/flightphp/core/d/monthly)](https://packagist.org/packages/flightphp/core) +![PHPStan: Level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) +[![License](http://poser.pugx.org/flightphp/core/license)](https://packagist.org/packages/flightphp/core) +[![PHP Version Require](http://poser.pugx.org/flightphp/core/require/php)](https://packagist.org/packages/flightphp/core) ![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix) # What is Flight? diff --git a/composer.json b/composer.json index eeae5b4..bf9a243 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ } ], "require": { - "php": "^7.4|^8.0|^8.1|^8.2|^8.3", + "php": ">=7.4", "ext-json": "*" }, "autoload": { @@ -41,6 +41,7 @@ }, "require-dev": { "ext-pdo_sqlite": "*", + "flightphp/runway": "^0.2.0", "league/container": "^4.2", "level-2/dice": "^4.0", "phpstan/extension-installer": "^1.3", @@ -64,7 +65,10 @@ "test-coverage:win": "del clover.xml && phpunit --coverage-html=coverage --coverage-clover=clover.xml && coverage-check clover.xml 100", "lint": "phpstan --no-progress -cphpstan.neon", "beautify": "phpcbf --standard=phpcs.xml", - "phpcs": "phpcs --standard=phpcs.xml -n" + "phpcs": "phpcs --standard=phpcs.xml -n", + "post-install-cmd": [ + "php -r \"if (!file_exists('phpcs.xml')) copy('phpcs.xml.dist', 'phpcs.xml');\"" + ] }, "suggest": { "latte/latte": "Latte template engine", diff --git a/flight/Engine.php b/flight/Engine.php index 62342b6..a1378a6 100644 --- a/flight/Engine.php +++ b/flight/Engine.php @@ -57,6 +57,8 @@ use flight\net\Route; * @method void redirect(string $url, int $code = 303) Redirects the current request to another URL. * @method void json(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * Sends a JSON response. + * @method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * Sends a JSON response and immediately halts the request. * @method void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) * Sends a JSONP response. * @@ -73,7 +75,7 @@ class Engine */ private const MAPPABLE_METHODS = [ 'start', 'stop', 'route', 'halt', 'error', 'notFound', - 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonp', + 'render', 'redirect', 'etag', 'lastModified', 'json', 'jsonHalt', 'jsonp', 'post', 'put', 'patch', 'delete', 'group', 'getUrl' ]; @@ -314,7 +316,7 @@ class Engine */ public function get(?string $key = null) { - if (null === $key) { + if ($key === null) { return $this->vars; } @@ -360,7 +362,7 @@ class Engine */ public function clear(?string $key = null): void { - if (null === $key) { + if ($key === null) { $this->vars = []; return; } @@ -382,64 +384,81 @@ class Engine * Processes each routes middleware. * * @param Route $route The route to process the middleware for. - * @param string $event_name If this is the before or after method. + * @param string $eventName If this is the before or after method. */ - protected function processMiddleware(Route $route, string $event_name): bool + protected function processMiddleware(Route $route, string $eventName): bool { - $at_least_one_middleware_failed = false; + $atLeastOneMiddlewareFailed = false; - $middlewares = $event_name === Dispatcher::FILTER_BEFORE ? $route->middleware : array_reverse($route->middleware); + // Process things normally for before, and then in reverse order for after. + $middlewares = $eventName === Dispatcher::FILTER_BEFORE + ? $route->middleware + : array_reverse($route->middleware); $params = $route->params; foreach ($middlewares as $middleware) { - $middleware_object = false; - - if ($event_name === Dispatcher::FILTER_BEFORE) { - // can be a callable or a class - $middleware_object = (is_callable($middleware) === true - ? $middleware - : (method_exists($middleware, Dispatcher::FILTER_BEFORE) === true - ? [$middleware, Dispatcher::FILTER_BEFORE] - : false - ) - ); - } elseif ($event_name === Dispatcher::FILTER_AFTER) { - // must be an object. No functions allowed here - if ( - is_object($middleware) === true - && !($middleware instanceof Closure) - && method_exists($middleware, Dispatcher::FILTER_AFTER) === true - ) { - $middleware_object = [$middleware, Dispatcher::FILTER_AFTER]; + // Assume that nothing is going to be executed for the middleware. + $middlewareObject = false; + + // Closure functions can only run on the before event + if ($eventName === Dispatcher::FILTER_BEFORE && is_object($middleware) === true && ($middleware instanceof Closure)) { + $middlewareObject = $middleware; + + // If the object has already been created, we can just use it if the event name exists. + } elseif (is_object($middleware) === true) { + $middlewareObject = method_exists($middleware, $eventName) === true ? [ $middleware, $eventName ] : false; + + // If the middleware is a string, we need to create the object and then call the event. + } elseif (is_string($middleware) === true && method_exists($middleware, $eventName) === true) { + $resolvedClass = null; + + // if there's a container assigned, we should use it to create the object + if ($this->dispatcher->mustUseContainer($middleware) === true) { + $resolvedClass = $this->dispatcher->resolveContainerClass($middleware, $params); + // otherwise just assume it's a plain jane class, so inject the engine + // just like in Dispatcher::invokeCallable() + } elseif (class_exists($middleware) === true) { + $resolvedClass = new $middleware($this); + } + + // If something was resolved, create an array callable that will be passed in later. + if ($resolvedClass !== null) { + $middlewareObject = [ $resolvedClass, $eventName ]; } } - if ($middleware_object === false) { + // If nothing was resolved, go to the next thing + if ($middlewareObject === false) { continue; } - $use_v3_output_buffering = + // This is the way that v3 handles output buffering (which captures output correctly) + $useV3OutputBuffering = $this->response()->v2_output_buffering === false && $route->is_streamed === false; - if ($use_v3_output_buffering === true) { + if ($useV3OutputBuffering === true) { ob_start(); } - // It's assumed if you don't declare before, that it will be assumed as the before method - $middleware_result = $middleware_object($params); + // Here is the array callable $middlewareObject that we created earlier. + // It looks bizarre but it's really calling [ $class, $method ]($params) + // Which loosely translates to $class->$method($params) + $middlewareResult = $middlewareObject($params); - if ($use_v3_output_buffering === true) { + if ($useV3OutputBuffering === true) { $this->response()->write(ob_get_clean()); } - if ($middleware_result === false) { - $at_least_one_middleware_failed = true; + // If you return false in your middleware, it will halt the request + // and throw a 403 forbidden error by default. + if ($middlewareResult === false) { + $atLeastOneMiddlewareFailed = true; break; } } - return $at_least_one_middleware_failed; + return $atLeastOneMiddlewareFailed; } //////////////////////// @@ -475,7 +494,7 @@ class Engine } // Route the request - $failed_middleware_check = false; + $failedMiddlewareCheck = false; while ($route = $router->route($request)) { $params = array_values($route->params); @@ -487,13 +506,16 @@ class Engine // If this route is to be streamed, we need to output the headers now if ($route->is_streamed === true) { - $response->status($route->streamed_headers['status']); - unset($route->streamed_headers['status']); + if (count($route->streamed_headers) > 0) { + $response->status($route->streamed_headers['status'] ?? 200); + unset($route->streamed_headers['status']); + foreach ($route->streamed_headers as $header => $value) { + $response->header($header, $value); + } + } + $response->header('X-Accel-Buffering', 'no'); $response->header('Connection', 'close'); - foreach ($route->streamed_headers as $header => $value) { - $response->header($header, $value); - } // We obviously don't know the content length right now. This must be false. $response->content_length = false; @@ -503,18 +525,18 @@ class Engine // Run any before middlewares if (count($route->middleware) > 0) { - $at_least_one_middleware_failed = $this->processMiddleware($route, 'before'); - if ($at_least_one_middleware_failed === true) { - $failed_middleware_check = true; + $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'before'); + if ($atLeastOneMiddlewareFailed === true) { + $failedMiddlewareCheck = true; break; } } - $use_v3_output_buffering = + $useV3OutputBuffering = $this->response()->v2_output_buffering === false && $route->is_streamed === false; - if ($use_v3_output_buffering === true) { + if ($useV3OutputBuffering === true) { ob_start(); } @@ -524,17 +546,17 @@ class Engine $params ); - if ($use_v3_output_buffering === true) { + if ($useV3OutputBuffering === true) { $response->write(ob_get_clean()); } // Run any before middlewares if (count($route->middleware) > 0) { // process the middleware in reverse order now - $at_least_one_middleware_failed = $this->processMiddleware($route, 'after'); + $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'after'); - if ($at_least_one_middleware_failed === true) { - $failed_middleware_check = true; + if ($atLeastOneMiddlewareFailed === true) { + $failedMiddlewareCheck = true; break; } } @@ -555,10 +577,16 @@ class Engine $response->clearBody(); } - if ($failed_middleware_check === true) { + if ($failedMiddlewareCheck === true) { $this->halt(403, 'Forbidden', empty(getenv('PHPUNIT_TEST'))); } elseif ($dispatched === false) { - $this->notFound(); + // Get the previous route and check if the method failed, but the URL was good. + $lastRouteExecuted = $router->executedRoute; + if ($lastRouteExecuted !== null && $lastRouteExecuted->matchUrl($request->url) === true && $lastRouteExecuted->matchMethod($request->method) === false) { + $this->halt(405, 'Method Not Allowed', empty(getenv('PHPUNIT_TEST'))); + } else { + $this->notFound(); + } } } @@ -570,9 +598,11 @@ class Engine public function _error(Throwable $e): void { $msg = sprintf( - '

500 Internal Server Error

' . - '

%s (%s)

' . - '
%s
', + <<500 Internal Server Error +

%s (%s)

+
%s
+ HTML, $e->getMessage(), $e->getCode(), $e->getTraceAsString() @@ -580,7 +610,7 @@ class Engine try { $this->response() - ->clear() + ->clearBody() ->status(500) ->write($msg) ->send(); @@ -603,8 +633,8 @@ class Engine { $response = $this->response(); - if (!$response->sent()) { - if (null !== $code) { + if ($response->sent() === false) { + if ($code !== null) { $response->status($code); } @@ -647,10 +677,12 @@ class Engine * @param string $pattern URL pattern to match * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route */ - public function _post(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void + public function _post(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route { - $this->router()->map('POST ' . $pattern, $callback, $pass_route, $route_alias); + return $this->router()->map('POST ' . $pattern, $callback, $pass_route, $route_alias); } /** @@ -659,10 +691,12 @@ class Engine * @param string $pattern URL pattern to match * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route */ - public function _put(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void + public function _put(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route { - $this->router()->map('PUT ' . $pattern, $callback, $pass_route, $route_alias); + return $this->router()->map('PUT ' . $pattern, $callback, $pass_route, $route_alias); } /** @@ -671,10 +705,12 @@ class Engine * @param string $pattern URL pattern to match * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route */ - public function _patch(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void + public function _patch(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route { - $this->router()->map('PATCH ' . $pattern, $callback, $pass_route, $route_alias); + return $this->router()->map('PATCH ' . $pattern, $callback, $pass_route, $route_alias); } /** @@ -683,10 +719,12 @@ class Engine * @param string $pattern URL pattern to match * @param callable|string $callback Callback function or string class->method * @param bool $pass_route Pass the matching route object to the callback + * + * @return Route */ - public function _delete(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): void + public function _delete(string $pattern, $callback, bool $pass_route = false, string $route_alias = ''): Route { - $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias); + return $this->router()->map('DELETE ' . $pattern, $callback, $pass_route, $route_alias); } /** @@ -699,7 +737,7 @@ class Engine public function _halt(int $code = 200, string $message = '', bool $actuallyExit = true): void { $this->response() - ->clear() + ->clearBody() ->status($code) ->write($message) ->send(); @@ -714,7 +752,7 @@ class Engine $output = '

404 Not Found

The page you have requested could not be found.

'; $this->response() - ->clear() + ->clearBody() ->status(404) ->write($output) ->send(); @@ -729,17 +767,17 @@ class Engine { $base = $this->get('flight.base_url'); - if (null === $base) { + if ($base === null) { $base = $this->request()->base; } // Append base url to redirect url - if ('/' !== $base && false === strpos($url, '://')) { + if ($base !== '/' && strpos($url, '://') === false) { $url = $base . preg_replace('#/+#', '/', '/' . $url); } $this->response() - ->clear() + ->clearBody() ->status($code) ->header('Location', $url) ->send(); @@ -756,7 +794,7 @@ class Engine */ public function _render(string $file, ?array $data = null, ?string $key = null): void { - if (null !== $key) { + if ($key !== null) { $this->view()->set($key, $this->view()->fetch($file, $data)); return; } @@ -793,6 +831,33 @@ class Engine } } + /** + * Sends a JSON response and halts execution immediately. + * + * @param mixed $data JSON data + * @param int $code HTTP status code + * @param bool $encode Whether to perform JSON encoding + * @param string $charset Charset + * @param int $option Bitmask Json constant such as JSON_HEX_QUOT + * + * @throws Exception + */ + public function _jsonHalt( + $data, + int $code = 200, + bool $encode = true, + string $charset = 'utf-8', + int $option = 0 + ): void { + $this->json($data, $code, $encode, $charset, $option); + $jsonBody = $this->response()->getBody(); + if ($this->response()->v2_output_buffering === false) { + $this->response()->clearBody(); + $this->response()->send(); + } + $this->halt($code, $jsonBody, empty(getenv('PHPUNIT_TEST'))); + } + /** * Sends a JSONP response. * @@ -833,7 +898,7 @@ class Engine */ public function _etag(string $id, string $type = 'strong'): void { - $id = (('weak' === $type) ? 'W/' : '') . $id; + $id = (($type === 'weak') ? 'W/' : '') . $id; $this->response()->header('ETag', '"' . str_replace('"', '\"', $id) . '"'); @@ -841,6 +906,7 @@ class Engine isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $id ) { + $this->response()->clear(); $this->halt(304, '', empty(getenv('PHPUNIT_TEST'))); } } @@ -858,6 +924,7 @@ class Engine isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time ) { + $this->response()->clear(); $this->halt(304, '', empty(getenv('PHPUNIT_TEST'))); } } diff --git a/flight/Flight.php b/flight/Flight.php index 0a42489..207d44b 100644 --- a/flight/Flight.php +++ b/flight/Flight.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use flight\core\Dispatcher; use flight\Engine; use flight\net\Request; use flight\net\Response; @@ -24,6 +23,10 @@ require_once __DIR__ . '/autoload.php'; * @method static void stop(?int $code = null) Stops the framework and sends a response. * @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true) * Stop the framework with an optional status code and message. + * @method static void register(string $name, string $class, array $params = [], ?callable $callback = null) + * Registers a class to a framework method. + * @method static void unregister(string $methodName) + * Unregisters a class to a framework method. * @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler. * * # Routing @@ -65,6 +68,8 @@ require_once __DIR__ . '/autoload.php'; * @method static void redirect(string $url, int $code = 303) Redirects to another URL. * @method static void json(mixed $data, int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * Sends a JSON response. + * @method void jsonHalt(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0) + * Sends a JSON response and immediately halts the request. * @method static void jsonp(mixed $data, string $param = 'jsonp', int $code = 200, bool $encode = true, string $charset = "utf8", int $encodeOption = 0, int $encodeDepth = 512) * Sends a JSONP response. * @method static void error(Throwable $exception) Sends an HTTP 500 response. diff --git a/flight/commands/ControllerCommand.php b/flight/commands/ControllerCommand.php new file mode 100644 index 0000000..9706d97 --- /dev/null +++ b/flight/commands/ControllerCommand.php @@ -0,0 +1,91 @@ + $config JSON config from .runway-config.json + */ + public function __construct(array $config) + { + parent::__construct('make:controller', 'Create a controller', $config); + $this->argument('', 'The name of the controller to create (with or without the Controller suffix)'); + } + + /** + * Executes the function + * + * @return void + */ + public function execute(string $controller) + { + $io = $this->app()->io(); + if (isset($this->config['app_root']) === false) { + $io->error('app_root not set in .runway-config.json', true); + return; + } + + if (!preg_match('/Controller$/', $controller)) { + $controller .= 'Controller'; + } + + $controllerPath = getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controller . '.php'; + if (file_exists($controllerPath) === true) { + $io->error($controller . ' already exists.', true); + return; + } + + if (is_dir(dirname($controllerPath)) === false) { + $io->info('Creating directory ' . dirname($controllerPath), true); + mkdir(dirname($controllerPath), 0755, true); + } + + $file = new PhpFile(); + $file->setStrictTypes(); + + $namespace = new PhpNamespace('app\\controllers'); + $namespace->addUse('flight\\Engine'); + + $class = new ClassType($controller); + $class->addProperty('app') + ->setVisibility('protected') + ->setType('flight\\Engine') + ->addComment('@var Engine'); + $method = $class->addMethod('__construct') + ->addComment('Constructor') + ->setVisibility('public') + ->setBody('$this->app = $app;'); + $method->addParameter('app') + ->setType('flight\\Engine'); + + $namespace->add($class); + $file->addNamespace($namespace); + + $this->persistClass($controller, $file); + + $io->ok('Controller successfully created at ' . $controllerPath, true); + } + + /** + * Saves the class name to a file + * + * @param string $controllerName Name of the Controller + * @param PhpFile $file Class Object from Nette\PhpGenerator + * + * @return void + */ + protected function persistClass(string $controllerName, PhpFile $file) + { + $printer = new \Nette\PhpGenerator\PsrPrinter(); + file_put_contents(getcwd() . DIRECTORY_SEPARATOR . $this->config['app_root'] . 'controllers' . DIRECTORY_SEPARATOR . $controllerName . '.php', $printer->printFile($file)); + } +} diff --git a/flight/commands/RouteCommand.php b/flight/commands/RouteCommand.php new file mode 100644 index 0000000..a34b821 --- /dev/null +++ b/flight/commands/RouteCommand.php @@ -0,0 +1,126 @@ + $config JSON config from .runway-config.json + */ + public function __construct(array $config) + { + parent::__construct('routes', 'Gets all routes for an application', $config); + + $this->option('--get', 'Only return GET requests'); + $this->option('--post', 'Only return POST requests'); + $this->option('--delete', 'Only return DELETE requests'); + $this->option('--put', 'Only return PUT requests'); + $this->option('--patch', 'Only return PATCH requests'); + } + + /** + * Executes the function + * + * @return void + */ + public function execute() + { + $io = $this->app()->io(); + + if (isset($this->config['index_root']) === false) { + $io->error('index_root not set in .runway-config.json', true); + return; + } + + $io->bold('Routes', true); + + $cwd = getcwd(); + + $index_root = $cwd . '/' . $this->config['index_root']; + + // This makes it so the framework doesn't actually execute + Flight::map('start', function () { + return; + }); + include($index_root); + $routes = Flight::router()->getRoutes(); + $arrayOfRoutes = []; + foreach ($routes as $route) { + if ($this->shouldAddRoute($route) === true) { + $middlewares = []; + if (!empty($route->middleware)) { + try { + $middlewares = array_map(function ($middleware) { + $middleware_class_name = explode("\\", get_class($middleware)); + return preg_match("/^class@anonymous/", end($middleware_class_name)) ? 'Anonymous' : end($middleware_class_name); + }, $route->middleware); + } catch (\TypeError $e) { + $middlewares[] = 'Bad Middleware'; + } finally { + if (is_string($route->middleware) === true) { + $middlewares[] = $route->middleware; + } + } + } + + $arrayOfRoutes[] = [ + 'Pattern' => $route->pattern, + 'Methods' => implode(', ', $route->methods), + 'Alias' => $route->alias ?? '', + 'Streamed' => $route->is_streamed ? 'Yes' : 'No', + 'Middleware' => !empty($middlewares) ? implode(",", $middlewares) : '-' + ]; + } + } + $io->table($arrayOfRoutes, [ + 'head' => 'boldGreen' + ]); + } + + /** + * Whether or not to add the route based on the request + * + * @param Route $route Flight Route object + * + * @return boolean + */ + public function shouldAddRoute(Route $route) + { + $boolval = false; + + $showAll = !$this->get && !$this->post && !$this->put && !$this->delete && !$this->patch; + if ($showAll === true) { + $boolval = true; + } else { + $methods = [ 'GET', 'POST', 'PUT', 'DELETE', 'PATCH' ]; + foreach ($methods as $method) { + $lowercaseMethod = strtolower($method); + if ( + $this->{$lowercaseMethod} === true && + ( + $route->methods[0] === '*' || + in_array($method, $route->methods, true) === true + ) + ) { + $boolval = true; + break; + } + } + } + return $boolval; + } +} diff --git a/flight/core/Dispatcher.php b/flight/core/Dispatcher.php index 6a167a8..20e27b8 100644 --- a/flight/core/Dispatcher.php +++ b/flight/core/Dispatcher.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace flight\core; -use Closure; use Exception; use flight\Engine; use InvalidArgumentException; +use Psr\Container\ContainerInterface; +use ReflectionFunction; +use Throwable; use TypeError; /** @@ -23,41 +25,54 @@ class Dispatcher { public const FILTER_BEFORE = 'before'; public const FILTER_AFTER = 'after'; - private const FILTER_TYPES = [self::FILTER_BEFORE, self::FILTER_AFTER]; - /** @var mixed $containerException Exception message if thrown by setting the container as a callable method */ - protected $containerException = null; + /** Exception message if thrown by setting the container as a callable method. */ + protected ?Throwable $containerException = null; - /** @var ?Engine $engine Engine instance */ + /** @var ?Engine $engine Engine instance. */ protected ?Engine $engine = null; - /** @var array Mapped events. */ + /** @var array Mapped events. */ protected array $events = []; /** * Method filters. * - * @var array &$params, mixed &$output): (void|false)>>> + * @var array &$params, mixed &$output): (void|false)>>> */ protected array $filters = []; /** * This is a container for the dependency injection. * - * @var callable|object|null + * @var null|ContainerInterface|(callable(string $classString, array $params): (null|object)) */ protected $containerHandler = null; /** * Sets the dependency injection container handler. * - * @param callable|object $containerHandler Dependency injection container + * @param ContainerInterface|(callable(string $classString, array $params): (null|object)) $containerHandler + * Dependency injection container. * - * @return void + * @throws InvalidArgumentException If $containerHandler is not a `callable` or instance of `Psr\Container\ContainerInterface`. */ public function setContainerHandler($containerHandler): void { - $this->containerHandler = $containerHandler; + $containerInterfaceNS = '\Psr\Container\ContainerInterface'; + + if ( + is_a($containerHandler, $containerInterfaceNS) + || is_callable($containerHandler) + ) { + $this->containerHandler = $containerHandler; + + return; + } + + throw new InvalidArgumentException( + "\$containerHandler must be of type callable or instance $containerInterfaceNS" + ); } public function setEngine(Engine $engine): void @@ -68,11 +83,11 @@ class Dispatcher /** * Dispatches an event. * - * @param string $name Event name + * @param string $name Event name. * @param array $params Callback parameters. * * @return mixed Output of callback - * @throws Exception If event name isn't found or if event throws an `Exception` + * @throws Exception If event name isn't found or if event throws an `Exception`. */ public function run(string $name, array $params = []) { @@ -110,7 +125,7 @@ class Dispatcher $requestedMethod = $this->get($eventName); if ($requestedMethod === null) { - throw new Exception("Event '{$eventName}' isn't found."); + throw new Exception("Event '$eventName' isn't found."); } return $this->execute($requestedMethod, $params); @@ -138,8 +153,8 @@ class Dispatcher /** * Assigns a callback to an event. * - * @param string $name Event name - * @param Closure(): (void|mixed) $callback Callback function + * @param string $name Event name. + * @param callable(): (void|mixed) $callback Callback function. * * @return $this */ @@ -153,9 +168,9 @@ class Dispatcher /** * Gets an assigned callback. * - * @param string $name Event name + * @param string $name Event name. * - * @return null|(Closure(): (void|mixed)) $callback Callback function + * @return null|(callable(): (void|mixed)) $callback Callback function. */ public function get(string $name): ?callable { @@ -165,9 +180,9 @@ class Dispatcher /** * Checks if an event has been set. * - * @param string $name Event name + * @param string $name Event name. * - * @return bool Event status + * @return bool If event exists or doesn't exists. */ public function has(string $name): bool { @@ -177,7 +192,7 @@ class Dispatcher /** * Clears an event. If no name is given, all events will be removed. * - * @param ?string $name Event name + * @param ?string $name Event name. */ public function clear(?string $name = null): void { @@ -188,27 +203,38 @@ class Dispatcher return; } - $this->events = []; - $this->filters = []; + $this->reset(); } /** * Hooks a callback to an event. * * @param string $name Event name - * @param 'before'|'after' $type Filter type - * @param Closure(array &$params, string &$output): (void|false) $callback + * @param 'before'|'after' $type Filter type. + * @param callable(array &$params, mixed &$output): (void|false)|callable(mixed &$output): (void|false) $callback * * @return $this */ public function hook(string $name, string $type, callable $callback): self { - if (!in_array($type, self::FILTER_TYPES, true)) { - $noticeMessage = "Invalid filter type '$type', use " . join('|', self::FILTER_TYPES); + static $filterTypes = [self::FILTER_BEFORE, self::FILTER_AFTER]; + + if (!in_array($type, $filterTypes, true)) { + $noticeMessage = "Invalid filter type '$type', use " . join('|', $filterTypes); trigger_error($noticeMessage, E_USER_NOTICE); } + if ($type === self::FILTER_AFTER) { + $callbackInfo = new ReflectionFunction($callback); + $parametersNumber = $callbackInfo->getNumberOfParameters(); + + if ($parametersNumber === 1) { + /** @disregard &$params in after filters are deprecated. */ + $callback = fn (array &$params, &$output) => $callback($output); + } + } + $this->filters[$name][$type][] = $callback; return $this; @@ -217,10 +243,10 @@ class Dispatcher /** * Executes a chain of method filters. * - * @param array &$params, mixed &$output): (void|false)> $filters - * Chain of filters- - * @param array $params Method parameters - * @param mixed $output Method output + * @param array &$params, mixed &$output): (void|false)> $filters + * Chain of filters. + * @param array $params Method parameters. + * @param mixed $output Method output. * * @throws Exception If an event throws an `Exception` or if `$filters` contains an invalid filter. */ @@ -242,16 +268,19 @@ class Dispatcher /** * Executes a callback function. * - * @param callable-string|(Closure(): mixed)|array{class-string|object, string} $callback - * Callback function - * @param array $params Function parameters + * @param callable-string|(callable(): mixed)|array{class-string|object, string} $callback + * Callback function. + * @param array $params Function parameters. * - * @return mixed Function results + * @return mixed Function results. * @throws Exception If `$callback` also throws an `Exception`. */ public function execute($callback, array &$params = []) { - if (is_string($callback) === true && (strpos($callback, '->') !== false || strpos($callback, '::') !== false)) { + if ( + is_string($callback) === true + && (strpos($callback, '->') !== false || strpos($callback, '::') !== false) + ) { $callback = $this->parseStringClassAndMethod($callback); } @@ -263,28 +292,26 @@ class Dispatcher * * @param string $classAndMethod Class and method * - * @return array{class-string|object, string} Class and method + * @return array{0: class-string|object, 1: string} Class and method */ public function parseStringClassAndMethod(string $classAndMethod): array { - $class_parts = explode('->', $classAndMethod); - if (count($class_parts) === 1) { - $class_parts = explode('::', $class_parts[0]); - } + $classParts = explode('->', $classAndMethod); - $class = $class_parts[0]; - $method = $class_parts[1]; + if (count($classParts) === 1) { + $classParts = explode('::', $classParts[0]); + } - return [ $class, $method ]; + return $classParts; } /** * Calls a function. * - * @param callable $func Name of function to call - * @param array &$params Function parameters + * @param callable $func Name of function to call. + * @param array &$params Function parameters. * - * @return mixed Function results + * @return mixed Function results. * @deprecated 3.7.0 Use invokeCallable instead */ public function callFunction(callable $func, array &$params = []) @@ -295,12 +322,12 @@ class Dispatcher /** * Invokes a method. * - * @param array{class-string|object, string} $func Class method - * @param array &$params Class method parameters + * @param array{0: class-string|object, 1: string} $func Class method. + * @param array &$params Class method parameters. * - * @return mixed Function results + * @return mixed Function results. * @throws TypeError For nonexistent class name. - * @deprecated 3.7.0 Use invokeCallable instead + * @deprecated 3.7.0 Use invokeCallable instead. */ public function invokeMethod(array $func, array &$params = []) { @@ -310,12 +337,12 @@ class Dispatcher /** * Invokes a callable (anonymous function or Class->method). * - * @param array{class-string|object, string}|Callable $func Class method - * @param array &$params Class method parameters + * @param array{0: class-string|object, 1: string}|callable $func Class method. + * @param array &$params Class method parameters. * - * @return mixed Function results + * @return mixed Function results. * @throws TypeError For nonexistent class name. - * @throws InvalidArgumentException If the constructor requires parameters + * @throws InvalidArgumentException If the constructor requires parameters. * @version 3.7.0 */ public function invokeCallable($func, array &$params = []) @@ -323,56 +350,43 @@ class Dispatcher // If this is a directly callable function, call it if (is_array($func) === false) { $this->verifyValidFunction($func); + return call_user_func_array($func, $params); } [$class, $method] = $func; - $resolvedClass = null; - // Only execute the container handler if it's not a Flight class - if ( - $this->containerHandler !== null && - ( - ( - is_object($class) === true && - strpos(get_class($class), 'flight\\') === false - ) || - is_string($class) === true - ) - ) { - $containerHandler = $this->containerHandler; - $resolvedClass = $this->resolveContainerClass($containerHandler, $class, $params); - if ($resolvedClass !== null) { + $mustUseTheContainer = $this->mustUseContainer($class); + + if ($mustUseTheContainer === true) { + $resolvedClass = $this->resolveContainerClass($class, $params); + + if ($resolvedClass) { $class = $resolvedClass; } } - $this->verifyValidClassCallable($class, $method, $resolvedClass); + $this->verifyValidClassCallable($class, $method, $resolvedClass ?? null); // Class is a string, and method exists, create the object by hand and inject only the Engine - if (is_string($class) === true) { + if (is_string($class)) { $class = new $class($this->engine); } - return call_user_func_array([ $class, $method ], $params); + return call_user_func_array([$class, $method], $params); } /** * Handles invalid callback types. * - * @param callable-string|(Closure(): mixed)|array{class-string|object, string} $callback - * Callback function + * @param callable-string|(callable(): mixed)|array{0: class-string|object, 1: string} $callback + * Callback function. * - * @throws InvalidArgumentException If `$callback` is an invalid type + * @throws InvalidArgumentException If `$callback` is an invalid type. */ protected function verifyValidFunction($callback): void { - $isInvalidFunctionName = ( - is_string($callback) - && !function_exists($callback) - ); - - if ($isInvalidFunctionName) { + if (is_string($callback) && !function_exists($callback)) { throw new InvalidArgumentException('Invalid callback specified.'); } } @@ -381,84 +395,92 @@ class Dispatcher /** * Verifies if the provided class and method are valid callable. * - * @param string|object $class The class name. + * @param class-string|object $class The class name. * @param string $method The method name. * @param object|null $resolvedClass The resolved class. * * @throws Exception If the class or method is not found. - * - * @return void */ protected function verifyValidClassCallable($class, $method, $resolvedClass): void { - $final_exception = null; + $exception = null; // Final check to make sure it's actually a class and a method, or throw an error if (is_object($class) === false && class_exists($class) === false) { - $final_exception = new Exception("Class '$class' not found. Is it being correctly autoloaded with Flight::path()?"); + $exception = new Exception("Class '$class' not found. Is it being correctly autoloaded with Flight::path()?"); - // If this tried to resolve a class in a container and failed somehow, throw the exception - } elseif (isset($resolvedClass) === false && $this->containerException !== null) { - $final_exception = $this->containerException; + // If this tried to resolve a class in a container and failed somehow, throw the exception + } elseif (!$resolvedClass && $this->containerException !== null) { + $exception = $this->containerException; - // Class is there, but no method + // Class is there, but no method } elseif (is_object($class) === true && method_exists($class, $method) === false) { - $final_exception = new Exception("Class found, but method '" . get_class($class) . "::$method' not found."); + $classNamespace = get_class($class); + $exception = new Exception("Class found, but method '$classNamespace::$method' not found."); } - if ($final_exception !== null) { + if ($exception !== null) { $this->fixOutputBuffering(); - throw $final_exception; + + throw $exception; } } /** * Resolves the container class. * - * @param callable|object $container_handler Dependency injection container - * @param class-string $class Class name - * @param array &$params Class constructor parameters + * @param class-string $class Class name. + * @param array &$params Class constructor parameters. * - * @return object Class object + * @return ?object Class object. */ - protected function resolveContainerClass($container_handler, $class, array &$params) + public function resolveContainerClass(string $class, array &$params) { - $class_object = null; - // PSR-11 if ( - is_object($container_handler) === true && - method_exists($container_handler, 'has') === true && - $container_handler->has($class) + is_a($this->containerHandler, '\Psr\Container\ContainerInterface') + && $this->containerHandler->has($class) ) { - $class_object = call_user_func([$container_handler, 'get'], $class); + return $this->containerHandler->get($class); + } // Just a callable where you configure the behavior (Dice, PHP-DI, etc.) - } elseif (is_callable($container_handler) === true) { - // This is to catch all the error that could be thrown by whatever container you are using + if (is_callable($this->containerHandler)) { + /* This is to catch all the error that could be thrown by whatever + container you are using */ try { - $class_object = call_user_func($container_handler, $class, $params); - } catch (Exception $e) { - // could not resolve a class for some reason - $class_object = null; + return ($this->containerHandler)($class, $params); + // could not resolve a class for some reason + } catch (Exception $exception) { // If the container throws an exception, we need to catch it // and store it somewhere. If we just let it throw itself, it // doesn't properly close the output buffers and can cause other // issues. - // This is thrown in the verifyValidClassCallable method - $this->containerException = $e; + // This is thrown in the verifyValidClassCallable method. + $this->containerException = $exception; } } - return $class_object; + return null; } /** - * Because this could throw an exception in the middle of an output buffer, + * Checks to see if a container should be used or not. * - * @return void + * @param string|object $class the class to verify + * + * @return boolean */ + public function mustUseContainer($class): bool + { + return $this->containerHandler !== null && ( + (is_object($class) === true && strpos(get_class($class), 'flight\\') === false) + || is_string($class) + ); + } + + /** Because this could throw an exception in the middle of an output buffer, */ protected function fixOutputBuffering(): void { // Cause PHPUnit has 1 level of output buffering by default diff --git a/flight/core/Loader.php b/flight/core/Loader.php index 9792949..1824b9c 100644 --- a/flight/core/Loader.php +++ b/flight/core/Loader.php @@ -25,6 +25,11 @@ class Loader */ protected array $classes = []; + /** + * If this is disabled, classes can load with underscores + */ + protected static bool $v2ClassLoading = true; + /** * Class instances. * @@ -190,14 +195,14 @@ class Loader */ public static function loadClass(string $class): void { - $classFile = str_replace(['\\', '_'], '/', $class) . '.php'; + $replace_chars = self::$v2ClassLoading === true ? ['\\', '_'] : ['\\']; + $classFile = str_replace($replace_chars, '/', $class) . '.php'; foreach (self::$dirs as $dir) { $filePath = "$dir/$classFile"; if (file_exists($filePath)) { require_once $filePath; - return; } } @@ -220,4 +225,17 @@ class Loader } } } + + + /** + * Sets the value for V2 class loading. + * + * @param bool $value The value to set for V2 class loading. + * + * @return void + */ + public static function setV2ClassLoading(bool $value): void + { + self::$v2ClassLoading = $value; + } } diff --git a/flight/net/Request.php b/flight/net/Request.php index 569994e..fd9194b 100644 --- a/flight/net/Request.php +++ b/flight/net/Request.php @@ -151,7 +151,7 @@ class Request 'method' => self::getMethod(), 'referrer' => self::getVar('HTTP_REFERER'), 'ip' => self::getVar('REMOTE_ADDR'), - 'ajax' => 'XMLHttpRequest' === self::getVar('HTTP_X_REQUESTED_WITH'), + 'ajax' => self::getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest', 'scheme' => self::getScheme(), 'user_agent' => self::getVar('HTTP_USER_AGENT'), 'type' => self::getVar('CONTENT_TYPE'), @@ -160,7 +160,7 @@ class Request 'data' => new Collection($_POST), 'cookies' => new Collection($_COOKIE), 'files' => new Collection($_FILES), - 'secure' => 'https' === self::getScheme(), + 'secure' => self::getScheme() === 'https', 'accept' => self::getVar('HTTP_ACCEPT'), 'proxy_ip' => self::getProxyIpAddress(), 'host' => self::getVar('HTTP_HOST'), @@ -188,12 +188,12 @@ class Request // This rewrites the url in case the public url and base directories match // (such as installing on a subdirectory in a web server) // @see testInitUrlSameAsBaseDirectory - if ('/' !== $this->base && '' !== $this->base && 0 === strpos($this->url, $this->base)) { + if ($this->base !== '/' && $this->base !== '' && strpos($this->url, $this->base) === 0) { $this->url = substr($this->url, \strlen($this->base)); } // Default url - if (empty($this->url)) { + if (empty($this->url) === true) { $this->url = '/'; } else { // Merge URL query parameters with $_GET @@ -203,11 +203,11 @@ class Request } // Check for JSON input - if (0 === strpos($this->type, 'application/json')) { + if (strpos($this->type, 'application/json') === 0) { $body = $this->getBody(); - if ('' !== $body) { + if ($body !== '') { $data = json_decode($body, true); - if (is_array($data)) { + if (is_array($data) === true) { $this->data->setData($data); } } @@ -225,13 +225,13 @@ class Request { $body = $this->body; - if ('' !== $body) { + if ($body !== '') { return $body; } $method = $this->method ?? self::getMethod(); - if ('POST' === $method || 'PUT' === $method || 'DELETE' === $method || 'PATCH' === $method) { + if ($method === 'POST' || $method === 'PUT' || $method === 'DELETE' || $method === 'PATCH') { $body = file_get_contents($this->stream_path); } @@ -247,9 +247,9 @@ class Request { $method = self::getVar('REQUEST_METHOD', 'GET'); - if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) { + if (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']) === true) { $method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']; - } elseif (isset($_REQUEST['_method'])) { + } elseif (isset($_REQUEST['_method']) === true) { $method = $_REQUEST['_method']; } @@ -275,9 +275,9 @@ class Request $flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE; foreach ($forwarded as $key) { - if (\array_key_exists($key, $_SERVER)) { + if (\array_key_exists($key, $_SERVER) === true) { sscanf($_SERVER[$key], '%[^,]', $ip); - if (false !== filter_var($ip, \FILTER_VALIDATE_IP, $flags)) { + if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) { return $ip; } } @@ -322,7 +322,7 @@ class Request { $headers = []; foreach ($_SERVER as $key => $value) { - if (0 === strpos($key, 'HTTP_')) { + if (strpos($key, 'HTTP_') === 0) { // converts headers like HTTP_CUSTOM_HEADER to Custom-Header $key = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5))))); $headers[$key] = $value; @@ -386,7 +386,7 @@ class Request $params = []; $args = parse_url($url); - if (isset($args['query'])) { + if (isset($args['query']) === true) { parse_str($args['query'], $params); } @@ -401,13 +401,13 @@ class Request public static function getScheme(): string { if ( - (isset($_SERVER['HTTPS']) && 'on' === strtolower($_SERVER['HTTPS'])) + (isset($_SERVER['HTTPS']) === true && strtolower($_SERVER['HTTPS']) === 'on') || - (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO']) + (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) === true && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') || - (isset($_SERVER['HTTP_FRONT_END_HTTPS']) && 'on' === $_SERVER['HTTP_FRONT_END_HTTPS']) + (isset($_SERVER['HTTP_FRONT_END_HTTPS']) === true && $_SERVER['HTTP_FRONT_END_HTTPS'] === 'on') || - (isset($_SERVER['REQUEST_SCHEME']) && 'https' === $_SERVER['REQUEST_SCHEME']) + (isset($_SERVER['REQUEST_SCHEME']) === true && $_SERVER['REQUEST_SCHEME'] === 'https') ) { return 'https'; } diff --git a/flight/net/Response.php b/flight/net/Response.php index e1abaac..1798de5 100644 --- a/flight/net/Response.php +++ b/flight/net/Response.php @@ -128,6 +128,13 @@ class Response */ protected bool $sent = false; + /** + * These are callbacks that can process the response body before it's sent + * + * @var array $responseBodyCallbacks + */ + protected array $responseBodyCallbacks = []; + /** * Sets the HTTP status of the response. * @@ -139,7 +146,7 @@ class Response */ public function status(?int $code = null) { - if (null === $code) { + if ($code === null) { return $this->status; } @@ -279,19 +286,22 @@ class Response */ public function cache($expires): self { - if (false === $expires) { + if ($expires === false) { $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; + $this->headers['Cache-Control'] = [ 'no-store, no-cache, must-revalidate', 'post-check=0, pre-check=0', 'max-age=0', ]; + $this->headers['Pragma'] = 'no-cache'; } else { $expires = \is_int($expires) ? $expires : strtotime($expires); $this->headers['Expires'] = gmdate('D, d M Y H:i:s', $expires) . ' GMT'; $this->headers['Cache-Control'] = 'max-age=' . ($expires - time()); - if (isset($this->headers['Pragma']) && 'no-cache' == $this->headers['Pragma']) { + + if (isset($this->headers['Pragma']) && $this->headers['Pragma'] === 'no-cache') { unset($this->headers['Pragma']); } } @@ -307,7 +317,7 @@ class Response public function sendHeaders(): self { // Send status code header - if (false !== strpos(\PHP_SAPI, 'cgi')) { + if (strpos(\PHP_SAPI, 'cgi') !== false) { // @codeCoverageIgnoreStart $this->setRealHeader( sprintf( @@ -331,6 +341,15 @@ class Response ); } + if ($this->content_length === true) { + // Send content length + $length = $this->getContentLength(); + + if ($length > 0) { + $this->setHeader('Content-Length', (string) $length); + } + } + // Send other headers foreach ($this->headers as $field => $value) { if (\is_array($value)) { @@ -342,15 +361,6 @@ class Response } } - if ($this->content_length) { - // Send content length - $length = $this->getContentLength(); - - if ($length > 0) { - $this->setRealHeader('Content-Length: ' . $length); - } - } - return $this; } @@ -422,7 +432,12 @@ class Response } } - if (!headers_sent()) { + // Only for the v3 output buffering. + if ($this->v2_output_buffering === false) { + $this->processResponseCallbacks(); + } + + if (headers_sent() === false) { $this->sendHeaders(); // @codeCoverageIgnore } @@ -430,4 +445,29 @@ class Response $this->sent = true; } + + /** + * Adds a callback to process the response body before it's sent. These are processed in the order + * they are added + * + * @param callable $callback The callback to process the response body + * + * @return void + */ + public function addResponseBodyCallback(callable $callback): void + { + $this->responseBodyCallbacks[] = $callback; + } + + /** + * Cycles through the response body callbacks and processes them in order + * + * @return void + */ + protected function processResponseCallbacks(): void + { + foreach ($this->responseBodyCallbacks as $callback) { + $this->body = $callback($this->body); + } + } } diff --git a/flight/net/Route.php b/flight/net/Route.php index 0b8b9d7..4e6e83c 100644 --- a/flight/net/Route.php +++ b/flight/net/Route.php @@ -63,7 +63,7 @@ class Route /** * The middleware to be applied to the route * - * @var array + * @var array */ public array $middleware = []; @@ -105,7 +105,7 @@ class Route public function matchUrl(string $url, bool $case_sensitive = false): bool { // Wildcard or exact match - if ('*' === $this->pattern || $this->pattern === $url) { + if ($this->pattern === '*' || $this->pattern === $url) { return true; } @@ -120,8 +120,9 @@ class Route for ($i = 0; $i < $len; $i++) { if ($url[$i] === '/') { - $n++; + ++$n; } + if ($n === $count) { break; } @@ -153,24 +154,20 @@ class Route $regex ); - if ('/' === $last_char) { // Fix trailing slash - $regex .= '?'; - } else { // Allow trailing slash - $regex .= '/?'; - } + $regex .= $last_char === '/' ? '?' : '/?'; // Attempt to match route and named parameters - if (preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { - foreach ($ids as $k => $v) { - $this->params[$k] = (\array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null; - } - - $this->regex = $regex; + if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { + return false; + } - return true; + foreach (array_keys($ids) as $k) { + $this->params[$k] = (\array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null; } - return false; + $this->regex = $regex; + + return true; } /** @@ -229,7 +226,7 @@ class Route /** * Sets the route middleware * - * @param array|callable $middleware + * @param array|callable|string $middleware */ public function addMiddleware($middleware): self { @@ -241,6 +238,17 @@ class Route return $this; } + /** + * If the response should be streamed + * + * @return self + */ + public function stream(): self + { + $this->is_streamed = true; + return $this; + } + /** * This will allow the response for this route to be streamed. * diff --git a/flight/net/Router.php b/flight/net/Router.php index d494dbb..a43b5ba 100644 --- a/flight/net/Router.php +++ b/flight/net/Router.php @@ -32,7 +32,7 @@ class Router /** * The current route that is has been found and executed. */ - protected ?Route $executedRoute = null; + public ?Route $executedRoute = null; /** * Pointer to current route. @@ -42,21 +42,21 @@ class Router /** * When groups are used, this is mapped against all the routes */ - protected string $group_prefix = ''; + protected string $groupPrefix = ''; /** * Group Middleware * * @var array */ - protected array $group_middlewares = []; + protected array $groupMiddlewares = []; /** * Allowed HTTP methods * * @var array */ - protected array $allowed_methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; + protected array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; /** * Gets mapped routes. @@ -93,7 +93,7 @@ class Router // Flight::route('', function() {}); // } // Keep the space so that it can execute the below code normally - if ($this->group_prefix !== '') { + if ($this->groupPrefix !== '') { $url = ltrim($pattern); } else { $url = trim($pattern); @@ -101,7 +101,7 @@ class Router $methods = ['*']; - if (false !== strpos($url, ' ')) { + if (strpos($url, ' ') !== false) { [$method, $url] = explode(' ', $url, 2); $url = trim($url); $methods = explode('|', $method); @@ -113,14 +113,14 @@ class Router } // And this finishes it off. - if ($this->group_prefix !== '') { - $url = rtrim($this->group_prefix . $url); + if ($this->groupPrefix !== '') { + $url = rtrim($this->groupPrefix . $url); } $route = new Route($url, $callback, $methods, $pass_route, $route_alias); // to handle group middleware - foreach ($this->group_middlewares as $gm) { + foreach ($this->groupMiddlewares as $gm) { $route->addMiddleware($gm); } @@ -197,20 +197,20 @@ class Router /** * Group together a set of routes * - * @param string $group_prefix group URL prefix (such as /api/v1) + * @param string $groupPrefix group URL prefix (such as /api/v1) * @param callable $callback The necessary calling that holds the Router class - * @param array $group_middlewares + * @param array $groupMiddlewares * The middlewares to be applied to the group. Example: `[$middleware1, $middleware2]` */ - public function group(string $group_prefix, callable $callback, array $group_middlewares = []): void + public function group(string $groupPrefix, callable $callback, array $groupMiddlewares = []): void { - $old_group_prefix = $this->group_prefix; - $old_group_middlewares = $this->group_middlewares; - $this->group_prefix .= $group_prefix; - $this->group_middlewares = array_merge($this->group_middlewares, $group_middlewares); + $oldGroupPrefix = $this->groupPrefix; + $oldGroupMiddlewares = $this->groupMiddlewares; + $this->groupPrefix .= $groupPrefix; + $this->groupMiddlewares = array_merge($this->groupMiddlewares, $groupMiddlewares); $callback($this); - $this->group_prefix = $old_group_prefix; - $this->group_middlewares = $old_group_middlewares; + $this->groupPrefix = $oldGroupPrefix; + $this->groupMiddlewares = $oldGroupMiddlewares; } /** @@ -221,9 +221,14 @@ class Router public function route(Request $request) { while ($route = $this->current()) { - if ($route->matchMethod($request->method) && $route->matchUrl($request->url, $this->case_sensitive)) { + $urlMatches = $route->matchUrl($request->url, $this->case_sensitive); + $methodMatches = $route->matchMethod($request->method); + if ($urlMatches === true && $methodMatches === true) { $this->executedRoute = $route; return $route; + // capture the route but don't execute it. We'll use this in Engine->start() to throw a 405 + } elseif ($urlMatches === true && $methodMatches === false) { + $this->executedRoute = $route; } $this->next(); } @@ -299,12 +304,20 @@ class Router return $this->routes[$this->index] ?? false; } + /** + * Gets the previous route. + */ + public function previous(): void + { + --$this->index; + } + /** * Gets the next route. */ public function next(): void { - $this->index++; + ++$this->index; } /** @@ -312,6 +325,6 @@ class Router */ public function reset(): void { - $this->index = 0; + $this->rewind(); } } diff --git a/flight/template/View.php b/flight/template/View.php index d1bc07f..15e4fc8 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -20,6 +20,8 @@ class View /** File extension. */ public string $extension = '.php'; + public bool $preserveVars = true; + /** * View variables. * @@ -88,7 +90,7 @@ class View */ public function clear(?string $key = null): self { - if (null === $key) { + if ($key === null) { $this->vars = []; } else { unset($this->vars[$key]); @@ -114,12 +116,16 @@ class View throw new \Exception("Template file not found: {$normalized_path}."); } - if (\is_array($data)) { - $this->vars = \array_merge($this->vars, $data); - } - \extract($this->vars); + if (\is_array($data) === true) { + \extract($data); + + if ($this->preserveVars === true) { + $this->vars = \array_merge($this->vars, $data); + } + } + include $this->template; } @@ -169,7 +175,7 @@ class View $is_windows = \strtoupper(\substr(PHP_OS, 0, 3)) === 'WIN'; - if (('/' == \substr($file, 0, 1)) || ($is_windows === true && ':' == \substr($file, 1, 1))) { + if ((\substr($file, 0, 1) === '/') || ($is_windows && \substr($file, 1, 1) === ':')) { return $file; } diff --git a/flight/util/Collection.php b/flight/util/Collection.php index 6ffe0b5..e17ed37 100644 --- a/flight/util/Collection.php +++ b/flight/util/Collection.php @@ -95,7 +95,7 @@ class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable #[\ReturnTypeWillChange] public function offsetSet($offset, $value): void { - if (null === $offset) { + if ($offset === null) { $this->data[] = $value; } else { $this->data[$offset] = $value; @@ -166,9 +166,7 @@ class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable */ public function valid(): bool { - $key = key($this->data); - - return null !== $key; + return key($this->data) !== null; } /** diff --git a/flight/util/ReturnTypeWillChange.php b/flight/util/ReturnTypeWillChange.php index 31a929b..1eba39e 100644 --- a/flight/util/ReturnTypeWillChange.php +++ b/flight/util/ReturnTypeWillChange.php @@ -3,7 +3,6 @@ declare(strict_types=1); // This file is only here so that the PHP8 attribute for doesn't throw an error in files -// phpcs:ignoreFile PSR1.Classes.ClassDeclaration.MissingNamespace class ReturnTypeWillChange { } diff --git a/index.php b/index.php index 5a21ed6..0db24be 100644 --- a/index.php +++ b/index.php @@ -1,7 +1,9 @@ - + - + - + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + flight/ tests/ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9fa597c..e69de29 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,6 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Parameter \\#2 \\$callback of method flight\\\\core\\\\Dispatcher\\:\\:set\\(\\) expects Closure\\(\\)\\: mixed, array\\{\\$this\\(flight\\\\Engine\\), literal\\-string&non\\-falsy\\-string\\} given\\.$#" - count: 1 - path: flight/Engine.php diff --git a/phpstan.neon b/phpstan.neon index 97e16eb..9be4ca5 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,7 @@ parameters: level: 6 excludePaths: - vendor + - flight/util/ReturnTypeWillChange.php paths: - flight - index.php diff --git a/phpunit.xml b/phpunit.xml index 2491e84..b890bb7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,10 +11,13 @@ stopOnFailure="true" verbose="true" colors="true"> - + flight/ + + flight/autoload.php + diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index a755666..418897d 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -37,9 +37,7 @@ class DispatcherTest extends TestCase public function testFunctionMapping(): void { - $this->dispatcher->set('map2', function (): string { - return 'hello'; - }); + $this->dispatcher->set('map2', fn (): string => 'hello'); $this->assertSame('hello', $this->dispatcher->run('map2')); } @@ -61,6 +59,9 @@ class DispatcherTest extends TestCase ->set('map-event', $customFunction) ->set('map-event-2', $anotherFunction); + $this->assertTrue($this->dispatcher->has('map-event')); + $this->assertTrue($this->dispatcher->has('map-event-2')); + $this->dispatcher->clear(); $this->assertFalse($this->dispatcher->has('map-event')); @@ -76,6 +77,9 @@ class DispatcherTest extends TestCase ->set('map-event', $customFunction) ->set('map-event-2', $anotherFunction); + $this->assertTrue($this->dispatcher->has('map-event')); + $this->assertTrue($this->dispatcher->has('map-event-2')); + $this->dispatcher->clear('map-event'); $this->assertFalse($this->dispatcher->has('map-event')); @@ -105,9 +109,7 @@ class DispatcherTest extends TestCase public function testBeforeAndAfter(): void { - $this->dispatcher->set('hello', function (string $name): string { - return "Hello, $name!"; - }); + $this->dispatcher->set('hello', fn (string $name): string => "Hello, $name!"); $this->dispatcher ->hook('hello', Dispatcher::FILTER_BEFORE, function (array &$params): void { @@ -124,6 +126,25 @@ class DispatcherTest extends TestCase $this->assertSame('Hello, Fred! Have a nice day!', $result); } + public function testBeforeAndAfterWithShortAfterFilterSyntax(): void + { + $this->dispatcher->set('hello', fn (string $name): string => "Hello, $name!"); + + $this->dispatcher + ->hook('hello', Dispatcher::FILTER_BEFORE, function (array &$params): void { + // Manipulate the parameter + $params[0] = 'Fred'; + }) + ->hook('hello', Dispatcher::FILTER_AFTER, function (string &$output): void { + // Manipulate the output + $output .= ' Have a nice day!'; + }); + + $result = $this->dispatcher->run('hello', ['Bob']); + + $this->assertSame('Hello, Fred! Have a nice day!', $result); + } + public function testInvalidCallback(): void { $this->expectException(Exception::class); @@ -245,7 +266,7 @@ class DispatcherTest extends TestCase public function testInvokeMethod(): void { $class = new TesterClass('param1', 'param2', 'param3', 'param4', 'param5', 'param6'); - $result = $this->dispatcher->invokeMethod([ $class, 'instanceMethod' ]); + $result = $this->dispatcher->invokeMethod([$class, 'instanceMethod']); $this->assertSame('param1', $class->param2); } @@ -271,7 +292,7 @@ class DispatcherTest extends TestCase public function testExecuteStringClassNoConstructArraySyntax(): void { - $result = $this->dispatcher->execute([ Hello::class, 'sayHi' ]); + $result = $this->dispatcher->execute([Hello::class, 'sayHi']); $this->assertSame('hello', $result); } @@ -298,7 +319,7 @@ class DispatcherTest extends TestCase $engine = new Engine(); $engine->set('test_me_out', 'You got it boss!'); $this->dispatcher->setEngine($engine); - $result = $this->dispatcher->execute([ ContainerDefault::class, 'testTheContainer' ]); + $result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']); $this->assertSame('You got it boss!', $result); } @@ -306,6 +327,6 @@ class DispatcherTest extends TestCase { $this->expectException(TypeError::class); $this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#'); - $result = $this->dispatcher->execute([ ContainerDefault::class, 'testTheContainer' ]); + $result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']); } } diff --git a/tests/DocExamplesTest.php b/tests/DocExamplesTest.php index 1518363..2bba482 100644 --- a/tests/DocExamplesTest.php +++ b/tests/DocExamplesTest.php @@ -74,4 +74,22 @@ class DocExamplesTest extends TestCase Flight::app()->handleException(new Exception('Error')); $this->expectOutputString('Custom: Error'); } + + public function testGetRouterStatically() + { + $router = Flight::router(); + Flight::request()->method = 'GET'; + Flight::request()->url = '/'; + + $router->get( + '/', + function () { + Flight::response()->write('from resp '); + } + ); + + Flight::start(); + + $this->expectOutputString('from resp '); + } } diff --git a/tests/EngineTest.php b/tests/EngineTest.php index bdf511e..93f3ff7 100644 --- a/tests/EngineTest.php +++ b/tests/EngineTest.php @@ -11,6 +11,7 @@ use flight\Engine; use flight\net\Request; use flight\net\Response; use flight\util\Collection; +use InvalidArgumentException; use PDOException; use PHPUnit\Framework\TestCase; use tests\classes\Container; @@ -86,8 +87,7 @@ class EngineTest extends TestCase public function testHandleException() { $engine = new Engine(); - $regex_message = preg_quote('

500 Internal Server Error

thrown exception message (20)

'); - $this->expectOutputRegex('~' . $regex_message . '~'); + $this->expectOutputRegex('~\500 Internal Server Error\[\s\S]*\thrown exception message \(20\)\~'); $engine->handleException(new Exception('thrown exception message', 20)); } @@ -370,6 +370,16 @@ class EngineTest extends TestCase $this->assertEquals(200, $engine->response()->status()); } + public function testJsonHalt() + { + $engine = new Engine(); + $this->expectOutputString('{"key1":"value1","key2":"value2"}'); + $engine->jsonHalt(['key1' => 'value1', 'key2' => 'value2']); + $this->assertEquals('application/json; charset=utf-8', $engine->response()->headers()['Content-Type']); + $this->assertEquals(200, $engine->response()->status()); + $this->assertEquals('{"key1":"value1","key2":"value2"}', $engine->response()->getBody()); + } + public function testJsonP() { $engine = new Engine(); @@ -572,6 +582,49 @@ class EngineTest extends TestCase $this->expectOutputString('OK123after123'); } + public function testMiddlewareClassStringNoContainer() + { + $middleware = new class { + public function after($params) + { + echo 'after' . $params['id']; + } + }; + $engine = new Engine(); + + $engine->route('/path1/@id', function ($id) { + echo 'OK' . $id; + }) + ->addMiddleware(get_class($middleware)); + $engine->request()->url = '/path1/123'; + $engine->start(); + $this->expectOutputString('OK123after123'); + } + + public function testMiddlewareClassStringWithContainer() + { + + $engine = new Engine(); + $dice = new \Dice\Dice(); + $dice = $dice->addRule('*', [ + 'substitutions' => [ + Engine::class => $engine + ] + ]); + $engine->registerContainerHandler(function ($class, $params) use ($dice) { + return $dice->create($class, $params); + }); + + + $engine->route('/path1/@id', function ($id) { + echo 'OK' . $id; + }) + ->addMiddleware(ContainerDefault::class); + $engine->request()->url = '/path1/123'; + $engine->start(); + $this->expectOutputString('I returned before the route was called with the following parameters: {"id":"123"}OK123'); + } + public function testMiddlewareClassAfterFailedCheck() { $middleware = new class { @@ -681,6 +734,14 @@ class EngineTest extends TestCase $this->expectOutputString('before456before123OKafter123456after123'); } + public function testContainerBadClass() { + $engine = new Engine(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("\$containerHandler must be of type callable or instance \\Psr\\Container\\ContainerInterface"); + $engine->registerContainerHandler('BadClass'); + } + public function testContainerDice() { $engine = new Engine(); $dice = new \Dice\Dice(); @@ -751,8 +812,14 @@ class EngineTest extends TestCase $engine->route('/container', Container::class.'->testThePdoWrapper'); $engine->request()->url = '/container'; - $this->expectException(ErrorException::class); - $this->expectExceptionMessageMatches("/Passing null to parameter/"); + // php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException + if(version_compare(PHP_VERSION, '8.0.0', '<')) { + $this->expectException(PDOException::class); + $this->expectExceptionMessageMatches("/invalid data source name/"); + } else { + $this->expectException(ErrorException::class); + $this->expectExceptionMessageMatches("/Passing null to parameter/"); + } $engine->start(); } @@ -842,4 +909,47 @@ class EngineTest extends TestCase $engine->start(); } + + public function testRouteFoundButBadMethod() { + $engine = new class extends Engine { + public function getLoader() + { + return $this->loader; + } + }; + // doing this so we can overwrite some parts of the response + $engine->getLoader()->register('response', function () { + return new class extends Response { + public function setRealHeader( + string $header_string, + bool $replace = true, + int $response_code = 0 + ): self { + return $this; + } + }; + }); + + $engine->route('POST /path1/@id', function ($id) { + echo 'OK' . $id; + }); + + $engine->route('GET /path2/@id', function ($id) { + echo 'OK' . $id; + }); + + $engine->route('PATCH /path3/@id', function ($id) { + echo 'OK' . $id; + }); + + $engine->request()->url = '/path1/123'; + $engine->request()->method = 'GET'; + + $engine->start(); + + $this->expectOutputString('Method Not Allowed'); + $this->assertEquals(405, $engine->response()->status()); + $this->assertEquals('Method Not Allowed', $engine->response()->getBody()); + } + } diff --git a/tests/FlightTest.php b/tests/FlightTest.php index 042b6bb..5d196d6 100644 --- a/tests/FlightTest.php +++ b/tests/FlightTest.php @@ -22,6 +22,7 @@ class FlightTest extends TestCase $_REQUEST = []; Flight::init(); Flight::setEngine(new Engine()); + Flight::set('flight.views.path', __DIR__ . '/views'); } protected function tearDown(): void @@ -280,6 +281,30 @@ class FlightTest extends TestCase } public function testStreamRoute() + { + $response_mock = new class extends Response { + public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response + { + return $this; + } + }; + $mock_response_class_name = get_class($response_mock); + Flight::register('response', $mock_response_class_name); + Flight::route('/stream', function () { + echo 'stream'; + })->stream(); + Flight::request()->url = '/stream'; + $this->expectOutputString('stream'); + Flight::start(); + $this->assertEquals('', Flight::response()->getBody()); + $this->assertEquals([ + 'X-Accel-Buffering' => 'no', + 'Connection' => 'close' + ], Flight::response()->getHeaders()); + $this->assertEquals(200, Flight::response()->status()); + } + + public function testStreamRouteWithHeaders() { $response_mock = new class extends Response { public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): Response @@ -330,4 +355,43 @@ class FlightTest extends TestCase $this->expectOutputString('Thisisaroutewithhtml'); } + + /** @dataProvider \tests\ViewTest::renderDataProvider */ + public function testDoesNotPreserveVarsWhenFlagIsDisabled( + string $output, + array $renderParams, + string $regexp + ): void + { + Flight::view()->preserveVars = false; + + $this->expectOutputString($output); + Flight::render(...$renderParams); + + set_error_handler(function (int $code, string $message) use ($regexp): void { + $this->assertMatchesRegularExpression($regexp, $message); + }); + + Flight::render($renderParams[0]); + + restore_error_handler(); + } + + public function testKeepThePreviousStateOfOneViewComponentByDefault(): void + { + $this->expectOutputString(<<Hi +
Hi
+ + + + + + html); + + Flight::render('myComponent', ['prop' => 'Hi']); + Flight::render('myComponent'); + Flight::render('input', ['type' => 'number']); + Flight::render('input'); + } } diff --git a/tests/LoaderTest.php b/tests/LoaderTest.php index 44a89d0..9b6047c 100644 --- a/tests/LoaderTest.php +++ b/tests/LoaderTest.php @@ -152,4 +152,17 @@ class LoaderTest extends TestCase __DIR__ . '/classes' ], $loader->getDirectories()); } + + public function testV2ClassLoading() + { + $loader = new class extends Loader { + public static function getV2ClassLoading() + { + return self::$v2ClassLoading; + } + }; + $this->assertTrue($loader::getV2ClassLoading()); + $loader::setV2ClassLoading(false); + $this->assertFalse($loader::getV2ClassLoading()); + } } diff --git a/tests/RedirectTest.php b/tests/RedirectTest.php index e44186e..07698eb 100644 --- a/tests/RedirectTest.php +++ b/tests/RedirectTest.php @@ -21,7 +21,7 @@ class RedirectTest extends TestCase public function getBaseUrl($base, $url) { - if ('/' !== $base && false === strpos($url, '://')) { + if ($base !== '/' && strpos($url, '://') === false) { $url = preg_replace('#/+#', '/', $base . '/' . $url); } @@ -67,11 +67,7 @@ class RedirectTest extends TestCase public function testBaseOverride() { $url = 'login'; - if (null !== $this->app->get('flight.base_url')) { - $base = $this->app->get('flight.base_url'); - } else { - $base = $this->app->request()->base; - } + $base = $this->app->get('flight.base_url') ?? $this->app->request()->base; self::assertEquals('/testdir/login', $this->getBaseUrl($base, $url)); } diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 42701d4..a163e7e 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -255,4 +255,54 @@ class ResponseTest extends TestCase $response->write('new', true); $this->assertEquals('new', $response->getBody()); } + + public function testResponseBodyCallback() + { + $response = new Response(); + $response->write('test'); + $str_rot13 = function ($body) { + return str_rot13($body); + }; + $response->addResponseBodyCallback($str_rot13); + ob_start(); + $response->send(); + $rot13_body = ob_get_clean(); + $this->assertEquals('grfg', $rot13_body); + } + + public function testResponseBodyCallbackGzip() + { + $response = new Response(); + $response->content_length = true; + $response->write('test'); + $gzip = function ($body) { + return gzencode($body); + }; + $response->addResponseBodyCallback($gzip); + ob_start(); + $response->send(); + $gzip_body = ob_get_clean(); + $expected = PHP_OS === 'WINNT' ? 'H4sIAAAAAAAACitJLS4BAAx+f9gEAAAA' : 'H4sIAAAAAAAAAytJLS4BAAx+f9gEAAAA'; + $this->assertEquals($expected, base64_encode($gzip_body)); + $this->assertEquals(strlen(gzencode('test')), strlen($gzip_body)); + } + + public function testResponseBodyCallbackMultiple() + { + $response = new Response(); + $response->write('test'); + $str_rot13 = function ($body) { + return str_rot13($body); + }; + $str_replace = function ($body) { + return str_replace('g', 'G', $body); + }; + $response->addResponseBodyCallback($str_rot13); + $response->addResponseBodyCallback($str_replace); + $response->addResponseBodyCallback($str_rot13); + ob_start(); + $response->send(); + $rot13_body = ob_get_clean(); + $this->assertEquals('TesT', $rot13_body); + } } diff --git a/tests/RouterTest.php b/tests/RouterTest.php index 2314426..f0ac765 100644 --- a/tests/RouterTest.php +++ b/tests/RouterTest.php @@ -336,12 +336,14 @@ class RouterTest extends TestCase { $this->router->map('GET /api/intune/hey', [$this, 'ok']); + $error_description = 'error_description=AADSTS65004%3a+User+declined+to+consent+to+access+the'; + $error_description .= '+app.%0d%0aTrace+ID%3a+747c0cc1-ccbd-4e53-8e2f-48812eb24100%0d%0a'; + $error_description .= 'Correlation+ID%3a+362e3cb3-20ef-400b-904e-9983bd989184%0d%0a'; + $error_description .= 'Timestamp%3a+2022-09-08+09%3a58%3a12Z'; + $query_params = [ 'error=access_denied', - 'error_description=AADSTS65004%3a+User+declined+to+consent+to+access+the' - . '+app.%0d%0aTrace+ID%3a+747c0cc1-ccbd-4e53-8e2f-48812eb24100%0d%0a' - . 'Correlation+ID%3a+362e3cb3-20ef-400b-904e-9983bd989184%0d%0a' - . 'Timestamp%3a+2022-09-08+09%3a58%3a12Z', + $error_description, 'error_uri=https%3a%2f%2flogin.microsoftonline.com%2ferror%3fcode%3d65004', 'admin_consent=True', 'state=x2EUE0fcSj#' @@ -629,6 +631,10 @@ class RouterTest extends TestCase $this->router->rewind(); $result = $this->router->valid(); $this->assertTrue($result); + + $this->router->previous(); + $result = $this->router->valid(); + $this->assertFalse($result); } public function testGetRootUrlByAlias() diff --git a/tests/ViewTest.php b/tests/ViewTest.php index fcb06c0..d6754c9 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -152,4 +152,84 @@ class ViewTest extends TestCase $viewMock::normalizePath('C:/xampp/htdocs/libs/Flight\core\index.php', '°') ); } + + /** @dataProvider renderDataProvider */ + public function testDoesNotPreserveVarsWhenFlagIsDisabled( + string $output, + array $renderParams, + string $regexp + ): void { + $this->view->preserveVars = false; + + $this->expectOutputString($output); + $this->view->render(...$renderParams); + + set_error_handler(function (int $code, string $message) use ($regexp): void { + $this->assertMatchesRegularExpression($regexp, $message); + }); + + $this->view->render($renderParams[0]); + + restore_error_handler(); + } + + public function testKeepThePreviousStateOfOneViewComponentByDefault(): void + { + $this->expectOutputString(<<Hi +
Hi
+ + + + + + html); + + $this->view->render('myComponent', ['prop' => 'Hi']); + $this->view->render('myComponent'); + $this->view->render('input', ['type' => 'number']); + $this->view->render('input'); + } + + public function testKeepThePreviousStateOfDataSettedBySetMethod(): void + { + $this->view->preserveVars = false; + + $this->view->set('prop', 'bar'); + + $this->expectOutputString(<<qux +
bar
+ + html); + + $this->view->render('myComponent', ['prop' => 'qux']); + $this->view->render('myComponent'); + } + + public static function renderDataProvider(): array + { + return [ + [ + <<Hi +
+ + html, + ['myComponent', ['prop' => 'Hi']], + '/^Undefined variable:? \$?prop$/' + ], + [ + << + + + + html, + ['input', ['type' => 'number']], + '/^.*$/' + ], + ]; + } } diff --git a/tests/classes/ContainerDefault.php b/tests/classes/ContainerDefault.php index a71ff1f..1f91aec 100644 --- a/tests/classes/ContainerDefault.php +++ b/tests/classes/ContainerDefault.php @@ -15,6 +15,11 @@ class ContainerDefault $this->app = $engine; } + public function before(array $params) + { + echo 'I returned before the route was called with the following parameters: ' . json_encode($params); + } + public function testTheContainer() { return $this->app->get('test_me_out'); diff --git a/tests/commands/ControllerCommandTest.php b/tests/commands/ControllerCommandTest.php new file mode 100644 index 0000000..82fb0c1 --- /dev/null +++ b/tests/commands/ControllerCommandTest.php @@ -0,0 +1,77 @@ + false); + + return $app->io(new Interactor(static::$in, static::$ou)); + } + + public function testConfigAppRootNotSet() + { + $app = $this->newApp('test', '0.0.1'); + $app->add(new ControllerCommand([])); + $app->handle(['runway', 'make:controller', 'Test']); + + $this->assertStringContainsString('app_root not set in .runway-config.json', file_get_contents(static::$ou)); + } + + public function testControllerAlreadyExists() + { + $app = $this->newApp('test', '0.0.1'); + mkdir(__DIR__ . '/controllers/'); + file_put_contents(__DIR__ . '/controllers/TestController.php', 'add(new ControllerCommand(['app_root' => 'tests/commands/'])); + $app->handle(['runway', 'make:controller', 'Test']); + + $this->assertStringContainsString('TestController already exists.', file_get_contents(static::$ou)); + } + + public function testCreateController() + { + $app = $this->newApp('test', '0.0.1'); + $app->add(new ControllerCommand(['app_root' => 'tests/commands/'])); + $app->handle(['runway', 'make:controller', 'Test']); + + $this->assertFileExists(__DIR__ . '/controllers/TestController.php'); + } +} diff --git a/tests/commands/RouteCommandTest.php b/tests/commands/RouteCommandTest.php new file mode 100644 index 0000000..eae0b81 --- /dev/null +++ b/tests/commands/RouteCommandTest.php @@ -0,0 +1,122 @@ + false); + + return $app->io(new Interactor(static::$in, static::$ou)); + } + + protected function createIndexFile() + { + $index = <<addMiddleware(function() {}); +Flight::delete('/delete', function () {}); +Flight::put('/put', function () {}); +Flight::patch('/patch', function () {})->addMiddleware('SomeMiddleware'); +Flight::router()->case_sensitive = true; + +Flight::start(); +PHP; + + file_put_contents(__DIR__ . '/index.php', $index); + } + + protected function removeColors(string $str): string + { + return preg_replace('/\e\[[\d;]*m/', '', $str); + } + + public function testConfigIndexRootNotSet() + { + $app = $this->newApp('test', '0.0.1'); + $app->add(new RouteCommand([])); + $app->handle(['runway', 'routes']); + + $this->assertStringContainsString('index_root not set in .runway-config.json', file_get_contents(static::$ou)); + } + + public function testGetRoutes() + { + $app = $this->newApp('test', '0.0.1'); + $this->createIndexFile(); + $app->add(new RouteCommand(['index_root' => 'tests/commands/index.php'])); + $app->handle(['runway', 'routes']); + + $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); + $this->assertStringContainsString('+---------+-----------+-------+----------+----------------+ +| Pattern | Methods | Alias | Streamed | Middleware | ++---------+-----------+-------+----------+----------------+ +| / | GET, HEAD | | No | - | +| /post | POST | | No | Closure | +| /delete | DELETE | | No | - | +| /put | PUT | | No | - | +| /patch | PATCH | | No | Bad Middleware | ++---------+-----------+-------+----------+----------------+', $this->removeColors(file_get_contents(static::$ou))); + } + + public function testGetPostRoute() + { + $app = $this->newApp('test', '0.0.1'); + $this->createIndexFile(); + $app->add(new RouteCommand(['index_root' => 'tests/commands/index.php'])); + $app->handle(['runway', 'routes', '--post']); + + $this->assertStringContainsString('Routes', file_get_contents(static::$ou)); + $this->assertStringContainsString('+---------+---------+-------+----------+------------+ +| Pattern | Methods | Alias | Streamed | Middleware | ++---------+---------+-------+----------+------------+ +| /post | POST | | No | Closure | ++---------+---------+-------+----------+------------+', $this->removeColors(file_get_contents(static::$ou))); + } +} diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index d38bf0d..72ec8ba 100644 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -1,9 +1,26 @@ #!/bin/bash -# Run all tests -composer lint -composer beautify -composer phpcs -composer test-coverage -xdg-open http://localhost:8000 -composer test-server \ No newline at end of file +php_versions=("php7.4" "php8.0" "php8.1" "php8.2" "php8.3") + +count=${#php_versions[@]} + + +echo "Prettifying code first" +vendor/bin/phpcbf --standard=phpcs.xml + +set -e +for ((i = 0; i < count; i++)); do + if type "${php_versions[$i]}" &> /dev/null; then + echo "Running tests for ${php_versions[$i]}" + echo " ${php_versions[$i]} vendor/bin/phpunit" + ${php_versions[$i]} vendor/bin/phpunit + + echo "Running PHPStan" + echo " ${php_versions[$i]} vendor/bin/phpstan" + ${php_versions[$i]} vendor/bin/phpstan + + echo "Running PHPCS" + echo " ${php_versions[$i]} vendor/bin/phpcs --standard=phpcs.xml -n" + ${php_versions[$i]} vendor/bin/phpcs --standard=phpcs.xml -n + fi +done \ No newline at end of file diff --git a/tests/server-v2/index.php b/tests/server-v2/index.php index 3c1951c..d0f8bcd 100644 --- a/tests/server-v2/index.php +++ b/tests/server-v2/index.php @@ -116,6 +116,10 @@ Flight::route('/jsonp', function () { echo "\n\n\n\n\n"; }); +Flight::route('/json-halt', function () { + Flight::jsonHalt(['message' => 'JSON rendered and halted successfully with no other body content!']); +}); + // Test 10: Halt Flight::route('/halt', function () { Flight::halt(400, 'Halt worked successfully'); @@ -200,6 +204,7 @@ echo '
  • Error
  • JSON
  • JSONP
  • +
  • JSON Halt
  • Halt
  • Redirect
  • '; diff --git a/tests/server/LayoutMiddleware.php b/tests/server/LayoutMiddleware.php index 500cbd7..2d55f24 100644 --- a/tests/server/LayoutMiddleware.php +++ b/tests/server/LayoutMiddleware.php @@ -74,15 +74,18 @@ class LayoutMiddleware
  • Error
  • JSON
  • JSONP
  • +
  • JSON Halt
  • Halt
  • Redirect
  • -
  • Stream
  • +
  • Stream Plain
  • +
  • Stream Headers
  • Overwrite Body
  • Slash in Param
  • UTF8 URL
  • UTF8 URL w/ Param
  • Dice Container
  • No Container Registered
  • +
  • Pascal_Snake_Case
  • HTML; echo '
    '; diff --git a/tests/server/Pascal_Snake_Case.php b/tests/server/Pascal_Snake_Case.php new file mode 100644 index 0000000..bbba0d2 --- /dev/null +++ b/tests/server/Pascal_Snake_Case.php @@ -0,0 +1,11 @@ +stream(); + + // Test 12: Redirect with status code + Flight::route('/streamWithHeaders', function () { + echo "Streaming a response"; + for ($i = 1; $i <= 50; $i++) { + echo "."; + usleep(50000); + ob_flush(); + } + echo "is successful!!"; })->streamWithHeaders(['Content-Type' => 'text/html', 'status' => 200 ]); + // Test 14: Overwrite the body with a middleware Flight::route('/overwrite', function () { echo 'Route text: This route status is that it failed'; @@ -147,6 +158,7 @@ Flight::group('', function () { Flight::set('test_me_out', 'You got it boss!'); // used in /no-container route Flight::route('/no-container', ContainerDefault::class . '->testUi'); Flight::route('/dice', Container::class . '->testThePdoWrapper'); + Flight::route('/Pascal_Snake_Case', Pascal_Snake_Case::class . '->doILoad'); }, [ new LayoutMiddleware() ]); // Test 9: JSON output (should not output any other html) @@ -159,11 +171,17 @@ Flight::route('/jsonp', function () { Flight::jsonp(['message' => 'JSONP renders successfully!'], 'jsonp'); }); +Flight::route('/json-halt', function () { + Flight::jsonHalt(['message' => 'JSON rendered and halted successfully with no other body content!']); +}); + Flight::map('error', function (Throwable $e) { echo sprintf( - '

    500 Internal Server Error

    ' . - '

    %s (%s)

    ' . - '
    %s
    ', + <<500 Internal Server Error +

    %s (%s)

    +
    %s
    + HTML, $e->getMessage(), $e->getCode(), str_replace(getenv('PWD'), '***CONFIDENTIAL***', $e->getTraceAsString()) diff --git a/tests/views/input.php b/tests/views/input.php new file mode 100644 index 0000000..19e7182 --- /dev/null +++ b/tests/views/input.php @@ -0,0 +1,7 @@ + + + diff --git a/tests/views/myComponent.php b/tests/views/myComponent.php new file mode 100644 index 0000000..cf0a36f --- /dev/null +++ b/tests/views/myComponent.php @@ -0,0 +1 @@ +