Merge branch 'master' into php8-named-arguments-support

php8-named-arguments-support
fadrian06 8 months ago
commit d6854cb078

@ -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.

@ -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.

5
.gitignore vendored

@ -1,4 +1,5 @@
.idea .idea/
.vscode/
vendor/ vendor/
composer.phar composer.phar
composer.lock composer.lock
@ -6,3 +7,5 @@ composer.lock
coverage/ coverage/
*.sublime* *.sublime*
clover.xml clover.xml
phpcs.xml
.runway-config.json

@ -1,5 +0,0 @@
{
"php.suggest.basic": false,
"editor.detectIndentation": false,
"editor.insertSpaces": true
}

@ -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

@ -1,5 +1,8 @@
![PHPStan: enabled](https://user-images.githubusercontent.com/104888/50957476-9c4acb80-14be-11e9-88ce-6447364dc1bb.png) [![Version](http://poser.pugx.org/flightphp/core/version)](https://packagist.org/packages/flightphp/core)
![PHPStan: level 6](https://img.shields.io/badge/PHPStan-level%206-brightgreen.svg?style=flat) [![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) ![Matrix](https://img.shields.io/matrix/flight-php-framework%3Amatrix.org?server_fqdn=matrix.org&style=social&logo=matrix)
# What is Flight? # What is Flight?

@ -23,7 +23,7 @@
} }
], ],
"require": { "require": {
"php": "^7.4|^8.0|^8.1|^8.2|^8.3", "php": ">=7.4",
"ext-json": "*" "ext-json": "*"
}, },
"autoload": { "autoload": {
@ -41,6 +41,7 @@
}, },
"require-dev": { "require-dev": {
"ext-pdo_sqlite": "*", "ext-pdo_sqlite": "*",
"flightphp/runway": "^0.2.0",
"league/container": "^4.2", "league/container": "^4.2",
"level-2/dice": "^4.0", "level-2/dice": "^4.0",
"phpstan/extension-installer": "^1.3", "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", "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", "lint": "phpstan --no-progress -cphpstan.neon",
"beautify": "phpcbf --standard=phpcs.xml", "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": { "suggest": {
"latte/latte": "Latte template engine", "latte/latte": "Latte template engine",

@ -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 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) * @method void json(mixed $data, int $code = 200, bool $encode = true, string $charset = 'utf-8', int $option = 0)
* Sends a JSON response. * 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) * @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. * Sends a JSONP response.
* *
@ -73,7 +75,7 @@ class Engine
*/ */
private const MAPPABLE_METHODS = [ private const MAPPABLE_METHODS = [
'start', 'stop', 'route', 'halt', 'error', 'notFound', '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' 'post', 'put', 'patch', 'delete', 'group', 'getUrl'
]; ];
@ -314,7 +316,7 @@ class Engine
*/ */
public function get(?string $key = null) public function get(?string $key = null)
{ {
if (null === $key) { if ($key === null) {
return $this->vars; return $this->vars;
} }
@ -360,7 +362,7 @@ class Engine
*/ */
public function clear(?string $key = null): void public function clear(?string $key = null): void
{ {
if (null === $key) { if ($key === null) {
$this->vars = []; $this->vars = [];
return; return;
} }
@ -382,64 +384,81 @@ class Engine
* Processes each routes middleware. * Processes each routes middleware.
* *
* @param Route $route The route to process the middleware for. * @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; $params = $route->params;
foreach ($middlewares as $middleware) { foreach ($middlewares as $middleware) {
$middleware_object = false; // Assume that nothing is going to be executed for the middleware.
$middlewareObject = false;
if ($event_name === Dispatcher::FILTER_BEFORE) {
// can be a callable or a class // Closure functions can only run on the before event
$middleware_object = (is_callable($middleware) === true if ($eventName === Dispatcher::FILTER_BEFORE && is_object($middleware) === true && ($middleware instanceof Closure)) {
? $middleware $middlewareObject = $middleware;
: (method_exists($middleware, Dispatcher::FILTER_BEFORE) === true
? [$middleware, Dispatcher::FILTER_BEFORE] // If the object has already been created, we can just use it if the event name exists.
: false } elseif (is_object($middleware) === true) {
) $middlewareObject = method_exists($middleware, $eventName) === true ? [ $middleware, $eventName ] : false;
);
} elseif ($event_name === Dispatcher::FILTER_AFTER) { // If the middleware is a string, we need to create the object and then call the event.
// must be an object. No functions allowed here } elseif (is_string($middleware) === true && method_exists($middleware, $eventName) === true) {
if ( $resolvedClass = null;
is_object($middleware) === true
&& !($middleware instanceof Closure) // if there's a container assigned, we should use it to create the object
&& method_exists($middleware, Dispatcher::FILTER_AFTER) === true if ($this->dispatcher->mustUseContainer($middleware) === true) {
) { $resolvedClass = $this->dispatcher->resolveContainerClass($middleware, $params);
$middleware_object = [$middleware, Dispatcher::FILTER_AFTER]; // 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; 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 && $this->response()->v2_output_buffering === false &&
$route->is_streamed === false; $route->is_streamed === false;
if ($use_v3_output_buffering === true) { if ($useV3OutputBuffering === true) {
ob_start(); ob_start();
} }
// It's assumed if you don't declare before, that it will be assumed as the before method // Here is the array callable $middlewareObject that we created earlier.
$middleware_result = $middleware_object($params); // 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()); $this->response()->write(ob_get_clean());
} }
if ($middleware_result === false) { // If you return false in your middleware, it will halt the request
$at_least_one_middleware_failed = true; // and throw a 403 forbidden error by default.
if ($middlewareResult === false) {
$atLeastOneMiddlewareFailed = true;
break; break;
} }
} }
return $at_least_one_middleware_failed; return $atLeastOneMiddlewareFailed;
} }
//////////////////////// ////////////////////////
@ -475,7 +494,7 @@ class Engine
} }
// Route the request // Route the request
$failed_middleware_check = false; $failedMiddlewareCheck = false;
while ($route = $router->route($request)) { while ($route = $router->route($request)) {
$params = array_values($route->params); $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 this route is to be streamed, we need to output the headers now
if ($route->is_streamed === true) { if ($route->is_streamed === true) {
$response->status($route->streamed_headers['status']); if (count($route->streamed_headers) > 0) {
unset($route->streamed_headers['status']); $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('X-Accel-Buffering', 'no');
$response->header('Connection', 'close'); $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. // We obviously don't know the content length right now. This must be false.
$response->content_length = false; $response->content_length = false;
@ -503,18 +525,18 @@ class Engine
// Run any before middlewares // Run any before middlewares
if (count($route->middleware) > 0) { if (count($route->middleware) > 0) {
$at_least_one_middleware_failed = $this->processMiddleware($route, 'before'); $atLeastOneMiddlewareFailed = $this->processMiddleware($route, 'before');
if ($at_least_one_middleware_failed === true) { if ($atLeastOneMiddlewareFailed === true) {
$failed_middleware_check = true; $failedMiddlewareCheck = true;
break; break;
} }
} }
$use_v3_output_buffering = $useV3OutputBuffering =
$this->response()->v2_output_buffering === false && $this->response()->v2_output_buffering === false &&
$route->is_streamed === false; $route->is_streamed === false;
if ($use_v3_output_buffering === true) { if ($useV3OutputBuffering === true) {
ob_start(); ob_start();
} }
@ -524,17 +546,17 @@ class Engine
$params $params
); );
if ($use_v3_output_buffering === true) { if ($useV3OutputBuffering === true) {
$response->write(ob_get_clean()); $response->write(ob_get_clean());
} }
// Run any before middlewares // Run any before middlewares
if (count($route->middleware) > 0) { if (count($route->middleware) > 0) {
// process the middleware in reverse order now // 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) { if ($atLeastOneMiddlewareFailed === true) {
$failed_middleware_check = true; $failedMiddlewareCheck = true;
break; break;
} }
} }
@ -555,10 +577,16 @@ class Engine
$response->clearBody(); $response->clearBody();
} }
if ($failed_middleware_check === true) { if ($failedMiddlewareCheck === true) {
$this->halt(403, 'Forbidden', empty(getenv('PHPUNIT_TEST'))); $this->halt(403, 'Forbidden', empty(getenv('PHPUNIT_TEST')));
} elseif ($dispatched === false) { } 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 public function _error(Throwable $e): void
{ {
$msg = sprintf( $msg = sprintf(
'<h1>500 Internal Server Error</h1>' . <<<HTML
'<h3>%s (%s)</h3>' . <h1>500 Internal Server Error</h1>
'<pre>%s</pre>', <h3>%s (%s)</h3>
<pre>%s</pre>
HTML,
$e->getMessage(), $e->getMessage(),
$e->getCode(), $e->getCode(),
$e->getTraceAsString() $e->getTraceAsString()
@ -580,7 +610,7 @@ class Engine
try { try {
$this->response() $this->response()
->clear() ->clearBody()
->status(500) ->status(500)
->write($msg) ->write($msg)
->send(); ->send();
@ -603,8 +633,8 @@ class Engine
{ {
$response = $this->response(); $response = $this->response();
if (!$response->sent()) { if ($response->sent() === false) {
if (null !== $code) { if ($code !== null) {
$response->status($code); $response->status($code);
} }
@ -647,10 +677,12 @@ class Engine
* @param string $pattern URL pattern to match * @param string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method * @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback * @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 string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method * @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback * @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 string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method * @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback * @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 string $pattern URL pattern to match
* @param callable|string $callback Callback function or string class->method * @param callable|string $callback Callback function or string class->method
* @param bool $pass_route Pass the matching route object to the callback * @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 public function _halt(int $code = 200, string $message = '', bool $actuallyExit = true): void
{ {
$this->response() $this->response()
->clear() ->clearBody()
->status($code) ->status($code)
->write($message) ->write($message)
->send(); ->send();
@ -714,7 +752,7 @@ class Engine
$output = '<h1>404 Not Found</h1><h3>The page you have requested could not be found.</h3>'; $output = '<h1>404 Not Found</h1><h3>The page you have requested could not be found.</h3>';
$this->response() $this->response()
->clear() ->clearBody()
->status(404) ->status(404)
->write($output) ->write($output)
->send(); ->send();
@ -729,17 +767,17 @@ class Engine
{ {
$base = $this->get('flight.base_url'); $base = $this->get('flight.base_url');
if (null === $base) { if ($base === null) {
$base = $this->request()->base; $base = $this->request()->base;
} }
// Append base url to redirect url // Append base url to redirect url
if ('/' !== $base && false === strpos($url, '://')) { if ($base !== '/' && strpos($url, '://') === false) {
$url = $base . preg_replace('#/+#', '/', '/' . $url); $url = $base . preg_replace('#/+#', '/', '/' . $url);
} }
$this->response() $this->response()
->clear() ->clearBody()
->status($code) ->status($code)
->header('Location', $url) ->header('Location', $url)
->send(); ->send();
@ -756,7 +794,7 @@ class Engine
*/ */
public function _render(string $file, ?array $data = null, ?string $key = null): void 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)); $this->view()->set($key, $this->view()->fetch($file, $data));
return; 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. * Sends a JSONP response.
* *
@ -833,7 +898,7 @@ class Engine
*/ */
public function _etag(string $id, string $type = 'strong'): void 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) . '"'); $this->response()->header('ETag', '"' . str_replace('"', '\"', $id) . '"');
@ -841,6 +906,7 @@ class Engine
isset($_SERVER['HTTP_IF_NONE_MATCH']) && isset($_SERVER['HTTP_IF_NONE_MATCH']) &&
$_SERVER['HTTP_IF_NONE_MATCH'] === $id $_SERVER['HTTP_IF_NONE_MATCH'] === $id
) { ) {
$this->response()->clear();
$this->halt(304, '', empty(getenv('PHPUNIT_TEST'))); $this->halt(304, '', empty(getenv('PHPUNIT_TEST')));
} }
} }
@ -858,6 +924,7 @@ class Engine
isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) &&
strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) === $time
) { ) {
$this->response()->clear();
$this->halt(304, '', empty(getenv('PHPUNIT_TEST'))); $this->halt(304, '', empty(getenv('PHPUNIT_TEST')));
} }
} }

@ -2,7 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
use flight\core\Dispatcher;
use flight\Engine; use flight\Engine;
use flight\net\Request; use flight\net\Request;
use flight\net\Response; 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 stop(?int $code = null) Stops the framework and sends a response.
* @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true) * @method static void halt(int $code = 200, string $message = '', bool $actuallyExit = true)
* Stop the framework with an optional status code and message. * 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. * @method static void registerContainerHandler(callable|object $containerHandler) Registers a container handler.
* *
* # Routing * # 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 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) * @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. * 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) * @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. * Sends a JSONP response.
* @method static void error(Throwable $exception) Sends an HTTP 500 response. * @method static void error(Throwable $exception) Sends an HTTP 500 response.

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace flight\commands;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\PhpFile;
use Nette\PhpGenerator\PhpNamespace;
class ControllerCommand extends AbstractBaseCommand
{
/**
* Construct
*
* @param array<string,mixed> $config JSON config from .runway-config.json
*/
public function __construct(array $config)
{
parent::__construct('make:controller', 'Create a controller', $config);
$this->argument('<controller>', '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));
}
}

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace flight\commands;
use Flight;
use flight\net\Route;
/**
* @property-read ?bool $get
* @property-read ?bool $post
* @property-read ?bool $delete
* @property-read ?bool $put
* @property-read ?bool $patch
*/
class RouteCommand extends AbstractBaseCommand
{
/**
* Construct
*
* @param array<string,mixed> $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;
}
}

@ -4,10 +4,12 @@ declare(strict_types=1);
namespace flight\core; namespace flight\core;
use Closure;
use Exception; use Exception;
use flight\Engine; use flight\Engine;
use InvalidArgumentException; use InvalidArgumentException;
use Psr\Container\ContainerInterface;
use ReflectionFunction;
use Throwable;
use TypeError; use TypeError;
/** /**
@ -23,41 +25,54 @@ class Dispatcher
{ {
public const FILTER_BEFORE = 'before'; public const FILTER_BEFORE = 'before';
public const FILTER_AFTER = 'after'; 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 */ /** Exception message if thrown by setting the container as a callable method. */
protected $containerException = null; protected ?Throwable $containerException = null;
/** @var ?Engine $engine Engine instance */ /** @var ?Engine $engine Engine instance. */
protected ?Engine $engine = null; protected ?Engine $engine = null;
/** @var array<string, Closure(): (void|mixed)> Mapped events. */ /** @var array<string, callable(): (void|mixed)> Mapped events. */
protected array $events = []; protected array $events = [];
/** /**
* Method filters. * Method filters.
* *
* @var array<string, array<'before'|'after', array<int, Closure(array<int, mixed> &$params, mixed &$output): (void|false)>>> * @var array<string, array<'before'|'after', array<int, callable(array<int, mixed> &$params, mixed &$output): (void|false)>>>
*/ */
protected array $filters = []; protected array $filters = [];
/** /**
* This is a container for the dependency injection. * This is a container for the dependency injection.
* *
* @var callable|object|null * @var null|ContainerInterface|(callable(string $classString, array<int, mixed> $params): (null|object))
*/ */
protected $containerHandler = null; protected $containerHandler = null;
/** /**
* Sets the dependency injection container handler. * Sets the dependency injection container handler.
* *
* @param callable|object $containerHandler Dependency injection container * @param ContainerInterface|(callable(string $classString, array<int, mixed> $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 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 public function setEngine(Engine $engine): void
@ -68,11 +83,11 @@ class Dispatcher
/** /**
* Dispatches an event. * Dispatches an event.
* *
* @param string $name Event name * @param string $name Event name.
* @param array<int, mixed> $params Callback parameters. * @param array<int, mixed> $params Callback parameters.
* *
* @return mixed Output of callback * @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 = []) public function run(string $name, array $params = [])
{ {
@ -110,7 +125,7 @@ class Dispatcher
$requestedMethod = $this->get($eventName); $requestedMethod = $this->get($eventName);
if ($requestedMethod === null) { if ($requestedMethod === null) {
throw new Exception("Event '{$eventName}' isn't found."); throw new Exception("Event '$eventName' isn't found.");
} }
return $this->execute($requestedMethod, $params); return $this->execute($requestedMethod, $params);
@ -138,8 +153,8 @@ class Dispatcher
/** /**
* Assigns a callback to an event. * Assigns a callback to an event.
* *
* @param string $name Event name * @param string $name Event name.
* @param Closure(): (void|mixed) $callback Callback function * @param callable(): (void|mixed) $callback Callback function.
* *
* @return $this * @return $this
*/ */
@ -153,9 +168,9 @@ class Dispatcher
/** /**
* Gets an assigned callback. * 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 public function get(string $name): ?callable
{ {
@ -165,9 +180,9 @@ class Dispatcher
/** /**
* Checks if an event has been set. * 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 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. * 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 public function clear(?string $name = null): void
{ {
@ -188,27 +203,38 @@ class Dispatcher
return; return;
} }
$this->events = []; $this->reset();
$this->filters = [];
} }
/** /**
* Hooks a callback to an event. * Hooks a callback to an event.
* *
* @param string $name Event name * @param string $name Event name
* @param 'before'|'after' $type Filter type * @param 'before'|'after' $type Filter type.
* @param Closure(array<int, mixed> &$params, string &$output): (void|false) $callback * @param callable(array<int, mixed> &$params, mixed &$output): (void|false)|callable(mixed &$output): (void|false) $callback
* *
* @return $this * @return $this
*/ */
public function hook(string $name, string $type, callable $callback): self public function hook(string $name, string $type, callable $callback): self
{ {
if (!in_array($type, self::FILTER_TYPES, true)) { static $filterTypes = [self::FILTER_BEFORE, self::FILTER_AFTER];
$noticeMessage = "Invalid filter type '$type', use " . join('|', self::FILTER_TYPES);
if (!in_array($type, $filterTypes, true)) {
$noticeMessage = "Invalid filter type '$type', use " . join('|', $filterTypes);
trigger_error($noticeMessage, E_USER_NOTICE); 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; $this->filters[$name][$type][] = $callback;
return $this; return $this;
@ -217,10 +243,10 @@ class Dispatcher
/** /**
* Executes a chain of method filters. * Executes a chain of method filters.
* *
* @param array<int, Closure(array<int, mixed> &$params, mixed &$output): (void|false)> $filters * @param array<int, callable(array<int, mixed> &$params, mixed &$output): (void|false)> $filters
* Chain of filters- * Chain of filters.
* @param array<int, mixed> $params Method parameters * @param array<int, mixed> $params Method parameters.
* @param mixed $output Method output * @param mixed $output Method output.
* *
* @throws Exception If an event throws an `Exception` or if `$filters` contains an invalid filter. * @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. * Executes a callback function.
* *
* @param callable-string|(Closure(): mixed)|array{class-string|object, string} $callback * @param callable-string|(callable(): mixed)|array{class-string|object, string} $callback
* Callback function * Callback function.
* @param array<int, mixed> $params Function parameters * @param array<int, mixed> $params Function parameters.
* *
* @return mixed Function results * @return mixed Function results.
* @throws Exception If `$callback` also throws an `Exception`. * @throws Exception If `$callback` also throws an `Exception`.
*/ */
public function execute($callback, array &$params = []) 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); $callback = $this->parseStringClassAndMethod($callback);
} }
@ -263,28 +292,26 @@ class Dispatcher
* *
* @param string $classAndMethod Class and method * @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 public function parseStringClassAndMethod(string $classAndMethod): array
{ {
$class_parts = explode('->', $classAndMethod); $classParts = explode('->', $classAndMethod);
if (count($class_parts) === 1) {
$class_parts = explode('::', $class_parts[0]);
}
$class = $class_parts[0]; if (count($classParts) === 1) {
$method = $class_parts[1]; $classParts = explode('::', $classParts[0]);
}
return [ $class, $method ]; return $classParts;
} }
/** /**
* Calls a function. * Calls a function.
* *
* @param callable $func Name of function to call * @param callable $func Name of function to call.
* @param array<int, mixed> &$params Function parameters * @param array<int, mixed> &$params Function parameters.
* *
* @return mixed Function results * @return mixed Function results.
* @deprecated 3.7.0 Use invokeCallable instead * @deprecated 3.7.0 Use invokeCallable instead
*/ */
public function callFunction(callable $func, array &$params = []) public function callFunction(callable $func, array &$params = [])
@ -295,12 +322,12 @@ class Dispatcher
/** /**
* Invokes a method. * Invokes a method.
* *
* @param array{class-string|object, string} $func Class method * @param array{0: class-string|object, 1: string} $func Class method.
* @param array<int, mixed> &$params Class method parameters * @param array<int, mixed> &$params Class method parameters.
* *
* @return mixed Function results * @return mixed Function results.
* @throws TypeError For nonexistent class name. * @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 = []) public function invokeMethod(array $func, array &$params = [])
{ {
@ -310,12 +337,12 @@ class Dispatcher
/** /**
* Invokes a callable (anonymous function or Class->method). * Invokes a callable (anonymous function or Class->method).
* *
* @param array{class-string|object, string}|Callable $func Class method * @param array{0: class-string|object, 1: string}|callable $func Class method.
* @param array<int, mixed> &$params Class method parameters * @param array<int, mixed> &$params Class method parameters.
* *
* @return mixed Function results * @return mixed Function results.
* @throws TypeError For nonexistent class name. * @throws TypeError For nonexistent class name.
* @throws InvalidArgumentException If the constructor requires parameters * @throws InvalidArgumentException If the constructor requires parameters.
* @version 3.7.0 * @version 3.7.0
*/ */
public function invokeCallable($func, array &$params = []) public function invokeCallable($func, array &$params = [])
@ -323,56 +350,43 @@ class Dispatcher
// If this is a directly callable function, call it // If this is a directly callable function, call it
if (is_array($func) === false) { if (is_array($func) === false) {
$this->verifyValidFunction($func); $this->verifyValidFunction($func);
return call_user_func_array($func, $params); return call_user_func_array($func, $params);
} }
[$class, $method] = $func; [$class, $method] = $func;
$resolvedClass = null;
// Only execute the container handler if it's not a Flight class $mustUseTheContainer = $this->mustUseContainer($class);
if (
$this->containerHandler !== null && if ($mustUseTheContainer === true) {
( $resolvedClass = $this->resolveContainerClass($class, $params);
(
is_object($class) === true && if ($resolvedClass) {
strpos(get_class($class), 'flight\\') === false
) ||
is_string($class) === true
)
) {
$containerHandler = $this->containerHandler;
$resolvedClass = $this->resolveContainerClass($containerHandler, $class, $params);
if ($resolvedClass !== null) {
$class = $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 // 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); $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. * Handles invalid callback types.
* *
* @param callable-string|(Closure(): mixed)|array{class-string|object, string} $callback * @param callable-string|(callable(): mixed)|array{0: class-string|object, 1: string} $callback
* Callback function * Callback function.
* *
* @throws InvalidArgumentException If `$callback` is an invalid type * @throws InvalidArgumentException If `$callback` is an invalid type.
*/ */
protected function verifyValidFunction($callback): void protected function verifyValidFunction($callback): void
{ {
$isInvalidFunctionName = ( if (is_string($callback) && !function_exists($callback)) {
is_string($callback)
&& !function_exists($callback)
);
if ($isInvalidFunctionName) {
throw new InvalidArgumentException('Invalid callback specified.'); throw new InvalidArgumentException('Invalid callback specified.');
} }
} }
@ -381,84 +395,92 @@ class Dispatcher
/** /**
* Verifies if the provided class and method are valid callable. * 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 string $method The method name.
* @param object|null $resolvedClass The resolved class. * @param object|null $resolvedClass The resolved class.
* *
* @throws Exception If the class or method is not found. * @throws Exception If the class or method is not found.
*
* @return void
*/ */
protected function verifyValidClassCallable($class, $method, $resolvedClass): 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 // 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) { 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 // If this tried to resolve a class in a container and failed somehow, throw the exception
} elseif (isset($resolvedClass) === false && $this->containerException !== null) { } elseif (!$resolvedClass && $this->containerException !== null) {
$final_exception = $this->containerException; $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) { } 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(); $this->fixOutputBuffering();
throw $final_exception;
throw $exception;
} }
} }
/** /**
* Resolves the container class. * Resolves the container class.
* *
* @param callable|object $container_handler Dependency injection container * @param class-string $class Class name.
* @param class-string $class Class name * @param array<int, mixed> &$params Class constructor parameters.
* @param array<int, mixed> &$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 // PSR-11
if ( if (
is_object($container_handler) === true && is_a($this->containerHandler, '\Psr\Container\ContainerInterface')
method_exists($container_handler, 'has') === true && && $this->containerHandler->has($class)
$container_handler->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.) // Just a callable where you configure the behavior (Dice, PHP-DI, etc.)
} elseif (is_callable($container_handler) === true) { if (is_callable($this->containerHandler)) {
// This is to catch all the error that could be thrown by whatever container you are using /* This is to catch all the error that could be thrown by whatever
container you are using */
try { try {
$class_object = call_user_func($container_handler, $class, $params); return ($this->containerHandler)($class, $params);
} catch (Exception $e) {
// could not resolve a class for some reason
$class_object = null;
// could not resolve a class for some reason
} catch (Exception $exception) {
// If the container throws an exception, we need to catch it // If the container throws an exception, we need to catch it
// and store it somewhere. If we just let it throw itself, it // and store it somewhere. If we just let it throw itself, it
// doesn't properly close the output buffers and can cause other // doesn't properly close the output buffers and can cause other
// issues. // issues.
// This is thrown in the verifyValidClassCallable method // This is thrown in the verifyValidClassCallable method.
$this->containerException = $e; $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 protected function fixOutputBuffering(): void
{ {
// Cause PHPUnit has 1 level of output buffering by default // Cause PHPUnit has 1 level of output buffering by default

@ -25,6 +25,11 @@ class Loader
*/ */
protected array $classes = []; protected array $classes = [];
/**
* If this is disabled, classes can load with underscores
*/
protected static bool $v2ClassLoading = true;
/** /**
* Class instances. * Class instances.
* *
@ -190,14 +195,14 @@ class Loader
*/ */
public static function loadClass(string $class): void 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) { foreach (self::$dirs as $dir) {
$filePath = "$dir/$classFile"; $filePath = "$dir/$classFile";
if (file_exists($filePath)) { if (file_exists($filePath)) {
require_once $filePath; require_once $filePath;
return; 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;
}
} }

@ -151,7 +151,7 @@ class Request
'method' => self::getMethod(), 'method' => self::getMethod(),
'referrer' => self::getVar('HTTP_REFERER'), 'referrer' => self::getVar('HTTP_REFERER'),
'ip' => self::getVar('REMOTE_ADDR'), 'ip' => self::getVar('REMOTE_ADDR'),
'ajax' => 'XMLHttpRequest' === self::getVar('HTTP_X_REQUESTED_WITH'), 'ajax' => self::getVar('HTTP_X_REQUESTED_WITH') === 'XMLHttpRequest',
'scheme' => self::getScheme(), 'scheme' => self::getScheme(),
'user_agent' => self::getVar('HTTP_USER_AGENT'), 'user_agent' => self::getVar('HTTP_USER_AGENT'),
'type' => self::getVar('CONTENT_TYPE'), 'type' => self::getVar('CONTENT_TYPE'),
@ -160,7 +160,7 @@ class Request
'data' => new Collection($_POST), 'data' => new Collection($_POST),
'cookies' => new Collection($_COOKIE), 'cookies' => new Collection($_COOKIE),
'files' => new Collection($_FILES), 'files' => new Collection($_FILES),
'secure' => 'https' === self::getScheme(), 'secure' => self::getScheme() === 'https',
'accept' => self::getVar('HTTP_ACCEPT'), 'accept' => self::getVar('HTTP_ACCEPT'),
'proxy_ip' => self::getProxyIpAddress(), 'proxy_ip' => self::getProxyIpAddress(),
'host' => self::getVar('HTTP_HOST'), 'host' => self::getVar('HTTP_HOST'),
@ -188,12 +188,12 @@ class Request
// This rewrites the url in case the public url and base directories match // This rewrites the url in case the public url and base directories match
// (such as installing on a subdirectory in a web server) // (such as installing on a subdirectory in a web server)
// @see testInitUrlSameAsBaseDirectory // @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)); $this->url = substr($this->url, \strlen($this->base));
} }
// Default url // Default url
if (empty($this->url)) { if (empty($this->url) === true) {
$this->url = '/'; $this->url = '/';
} else { } else {
// Merge URL query parameters with $_GET // Merge URL query parameters with $_GET
@ -203,11 +203,11 @@ class Request
} }
// Check for JSON input // Check for JSON input
if (0 === strpos($this->type, 'application/json')) { if (strpos($this->type, 'application/json') === 0) {
$body = $this->getBody(); $body = $this->getBody();
if ('' !== $body) { if ($body !== '') {
$data = json_decode($body, true); $data = json_decode($body, true);
if (is_array($data)) { if (is_array($data) === true) {
$this->data->setData($data); $this->data->setData($data);
} }
} }
@ -225,13 +225,13 @@ class Request
{ {
$body = $this->body; $body = $this->body;
if ('' !== $body) { if ($body !== '') {
return $body; return $body;
} }
$method = $this->method ?? self::getMethod(); $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); $body = file_get_contents($this->stream_path);
} }
@ -247,9 +247,9 @@ class Request
{ {
$method = self::getVar('REQUEST_METHOD', 'GET'); $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']; $method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
} elseif (isset($_REQUEST['_method'])) { } elseif (isset($_REQUEST['_method']) === true) {
$method = $_REQUEST['_method']; $method = $_REQUEST['_method'];
} }
@ -275,9 +275,9 @@ class Request
$flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE; $flags = \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE;
foreach ($forwarded as $key) { foreach ($forwarded as $key) {
if (\array_key_exists($key, $_SERVER)) { if (\array_key_exists($key, $_SERVER) === true) {
sscanf($_SERVER[$key], '%[^,]', $ip); sscanf($_SERVER[$key], '%[^,]', $ip);
if (false !== filter_var($ip, \FILTER_VALIDATE_IP, $flags)) { if (filter_var($ip, \FILTER_VALIDATE_IP, $flags) !== false) {
return $ip; return $ip;
} }
} }
@ -322,7 +322,7 @@ class Request
{ {
$headers = []; $headers = [];
foreach ($_SERVER as $key => $value) { foreach ($_SERVER as $key => $value) {
if (0 === strpos($key, 'HTTP_')) { if (strpos($key, 'HTTP_') === 0) {
// converts headers like HTTP_CUSTOM_HEADER to Custom-Header // converts headers like HTTP_CUSTOM_HEADER to Custom-Header
$key = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5))))); $key = str_replace(' ', '-', ucwords(str_replace('_', ' ', strtolower(substr($key, 5)))));
$headers[$key] = $value; $headers[$key] = $value;
@ -386,7 +386,7 @@ class Request
$params = []; $params = [];
$args = parse_url($url); $args = parse_url($url);
if (isset($args['query'])) { if (isset($args['query']) === true) {
parse_str($args['query'], $params); parse_str($args['query'], $params);
} }
@ -401,13 +401,13 @@ class Request
public static function getScheme(): string public static function getScheme(): string
{ {
if ( 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'; return 'https';
} }

@ -128,6 +128,13 @@ class Response
*/ */
protected bool $sent = false; protected bool $sent = false;
/**
* These are callbacks that can process the response body before it's sent
*
* @var array<int, callable> $responseBodyCallbacks
*/
protected array $responseBodyCallbacks = [];
/** /**
* Sets the HTTP status of the response. * Sets the HTTP status of the response.
* *
@ -139,7 +146,7 @@ class Response
*/ */
public function status(?int $code = null) public function status(?int $code = null)
{ {
if (null === $code) { if ($code === null) {
return $this->status; return $this->status;
} }
@ -279,19 +286,22 @@ class Response
*/ */
public function cache($expires): self public function cache($expires): self
{ {
if (false === $expires) { if ($expires === false) {
$this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT'; $this->headers['Expires'] = 'Mon, 26 Jul 1997 05:00:00 GMT';
$this->headers['Cache-Control'] = [ $this->headers['Cache-Control'] = [
'no-store, no-cache, must-revalidate', 'no-store, no-cache, must-revalidate',
'post-check=0, pre-check=0', 'post-check=0, pre-check=0',
'max-age=0', 'max-age=0',
]; ];
$this->headers['Pragma'] = 'no-cache'; $this->headers['Pragma'] = 'no-cache';
} else { } else {
$expires = \is_int($expires) ? $expires : strtotime($expires); $expires = \is_int($expires) ? $expires : strtotime($expires);
$this->headers['Expires'] = gmdate('D, d M Y H:i:s', $expires) . ' GMT'; $this->headers['Expires'] = gmdate('D, d M Y H:i:s', $expires) . ' GMT';
$this->headers['Cache-Control'] = 'max-age=' . ($expires - time()); $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']); unset($this->headers['Pragma']);
} }
} }
@ -307,7 +317,7 @@ class Response
public function sendHeaders(): self public function sendHeaders(): self
{ {
// Send status code header // Send status code header
if (false !== strpos(\PHP_SAPI, 'cgi')) { if (strpos(\PHP_SAPI, 'cgi') !== false) {
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
$this->setRealHeader( $this->setRealHeader(
sprintf( 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 // Send other headers
foreach ($this->headers as $field => $value) { foreach ($this->headers as $field => $value) {
if (\is_array($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; 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 $this->sendHeaders(); // @codeCoverageIgnore
} }
@ -430,4 +445,29 @@ class Response
$this->sent = true; $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);
}
}
} }

@ -63,7 +63,7 @@ class Route
/** /**
* The middleware to be applied to the route * The middleware to be applied to the route
* *
* @var array<int, callable|object> * @var array<int, callable|object|string>
*/ */
public array $middleware = []; public array $middleware = [];
@ -105,7 +105,7 @@ class Route
public function matchUrl(string $url, bool $case_sensitive = false): bool public function matchUrl(string $url, bool $case_sensitive = false): bool
{ {
// Wildcard or exact match // Wildcard or exact match
if ('*' === $this->pattern || $this->pattern === $url) { if ($this->pattern === '*' || $this->pattern === $url) {
return true; return true;
} }
@ -120,8 +120,9 @@ class Route
for ($i = 0; $i < $len; $i++) { for ($i = 0; $i < $len; $i++) {
if ($url[$i] === '/') { if ($url[$i] === '/') {
$n++; ++$n;
} }
if ($n === $count) { if ($n === $count) {
break; break;
} }
@ -153,24 +154,20 @@ class Route
$regex $regex
); );
if ('/' === $last_char) { // Fix trailing slash $regex .= $last_char === '/' ? '?' : '/?';
$regex .= '?';
} else { // Allow trailing slash
$regex .= '/?';
}
// Attempt to match route and named parameters // Attempt to match route and named parameters
if (preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) { if (!preg_match('#^' . $regex . '(?:\?[\s\S]*)?$#' . (($case_sensitive) ? '' : 'i'), $url, $matches)) {
foreach ($ids as $k => $v) { return false;
$this->params[$k] = (\array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null; }
}
$this->regex = $regex;
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 * Sets the route middleware
* *
* @param array<int, callable>|callable $middleware * @param array<int, callable|string>|callable|string $middleware
*/ */
public function addMiddleware($middleware): self public function addMiddleware($middleware): self
{ {
@ -241,6 +238,17 @@ class Route
return $this; 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. * This will allow the response for this route to be streamed.
* *

@ -32,7 +32,7 @@ class Router
/** /**
* The current route that is has been found and executed. * The current route that is has been found and executed.
*/ */
protected ?Route $executedRoute = null; public ?Route $executedRoute = null;
/** /**
* Pointer to current route. * Pointer to current route.
@ -42,21 +42,21 @@ class Router
/** /**
* When groups are used, this is mapped against all the routes * When groups are used, this is mapped against all the routes
*/ */
protected string $group_prefix = ''; protected string $groupPrefix = '';
/** /**
* Group Middleware * Group Middleware
* *
* @var array<int,mixed> * @var array<int,mixed>
*/ */
protected array $group_middlewares = []; protected array $groupMiddlewares = [];
/** /**
* Allowed HTTP methods * Allowed HTTP methods
* *
* @var array<int, string> * @var array<int, string>
*/ */
protected array $allowed_methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; protected array $allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
/** /**
* Gets mapped routes. * Gets mapped routes.
@ -93,7 +93,7 @@ class Router
// Flight::route('', function() {}); // Flight::route('', function() {});
// } // }
// Keep the space so that it can execute the below code normally // Keep the space so that it can execute the below code normally
if ($this->group_prefix !== '') { if ($this->groupPrefix !== '') {
$url = ltrim($pattern); $url = ltrim($pattern);
} else { } else {
$url = trim($pattern); $url = trim($pattern);
@ -101,7 +101,7 @@ class Router
$methods = ['*']; $methods = ['*'];
if (false !== strpos($url, ' ')) { if (strpos($url, ' ') !== false) {
[$method, $url] = explode(' ', $url, 2); [$method, $url] = explode(' ', $url, 2);
$url = trim($url); $url = trim($url);
$methods = explode('|', $method); $methods = explode('|', $method);
@ -113,14 +113,14 @@ class Router
} }
// And this finishes it off. // And this finishes it off.
if ($this->group_prefix !== '') { if ($this->groupPrefix !== '') {
$url = rtrim($this->group_prefix . $url); $url = rtrim($this->groupPrefix . $url);
} }
$route = new Route($url, $callback, $methods, $pass_route, $route_alias); $route = new Route($url, $callback, $methods, $pass_route, $route_alias);
// to handle group middleware // to handle group middleware
foreach ($this->group_middlewares as $gm) { foreach ($this->groupMiddlewares as $gm) {
$route->addMiddleware($gm); $route->addMiddleware($gm);
} }
@ -197,20 +197,20 @@ class Router
/** /**
* Group together a set of routes * 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 callable $callback The necessary calling that holds the Router class
* @param array<int, callable|object> $group_middlewares * @param array<int, callable|object> $groupMiddlewares
* The middlewares to be applied to the group. Example: `[$middleware1, $middleware2]` * 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; $oldGroupPrefix = $this->groupPrefix;
$old_group_middlewares = $this->group_middlewares; $oldGroupMiddlewares = $this->groupMiddlewares;
$this->group_prefix .= $group_prefix; $this->groupPrefix .= $groupPrefix;
$this->group_middlewares = array_merge($this->group_middlewares, $group_middlewares); $this->groupMiddlewares = array_merge($this->groupMiddlewares, $groupMiddlewares);
$callback($this); $callback($this);
$this->group_prefix = $old_group_prefix; $this->groupPrefix = $oldGroupPrefix;
$this->group_middlewares = $old_group_middlewares; $this->groupMiddlewares = $oldGroupMiddlewares;
} }
/** /**
@ -221,9 +221,14 @@ class Router
public function route(Request $request) public function route(Request $request)
{ {
while ($route = $this->current()) { 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; $this->executedRoute = $route;
return $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(); $this->next();
} }
@ -299,12 +304,20 @@ class Router
return $this->routes[$this->index] ?? false; return $this->routes[$this->index] ?? false;
} }
/**
* Gets the previous route.
*/
public function previous(): void
{
--$this->index;
}
/** /**
* Gets the next route. * Gets the next route.
*/ */
public function next(): void public function next(): void
{ {
$this->index++; ++$this->index;
} }
/** /**
@ -312,6 +325,6 @@ class Router
*/ */
public function reset(): void public function reset(): void
{ {
$this->index = 0; $this->rewind();
} }
} }

@ -20,6 +20,8 @@ class View
/** File extension. */ /** File extension. */
public string $extension = '.php'; public string $extension = '.php';
public bool $preserveVars = true;
/** /**
* View variables. * View variables.
* *
@ -88,7 +90,7 @@ class View
*/ */
public function clear(?string $key = null): self public function clear(?string $key = null): self
{ {
if (null === $key) { if ($key === null) {
$this->vars = []; $this->vars = [];
} else { } else {
unset($this->vars[$key]); unset($this->vars[$key]);
@ -114,12 +116,16 @@ class View
throw new \Exception("Template file not found: {$normalized_path}."); throw new \Exception("Template file not found: {$normalized_path}.");
} }
if (\is_array($data)) {
$this->vars = \array_merge($this->vars, $data);
}
\extract($this->vars); \extract($this->vars);
if (\is_array($data) === true) {
\extract($data);
if ($this->preserveVars === true) {
$this->vars = \array_merge($this->vars, $data);
}
}
include $this->template; include $this->template;
} }
@ -169,7 +175,7 @@ class View
$is_windows = \strtoupper(\substr(PHP_OS, 0, 3)) === 'WIN'; $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; return $file;
} }

@ -95,7 +95,7 @@ class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable
#[\ReturnTypeWillChange] #[\ReturnTypeWillChange]
public function offsetSet($offset, $value): void public function offsetSet($offset, $value): void
{ {
if (null === $offset) { if ($offset === null) {
$this->data[] = $value; $this->data[] = $value;
} else { } else {
$this->data[$offset] = $value; $this->data[$offset] = $value;
@ -166,9 +166,7 @@ class Collection implements ArrayAccess, Iterator, Countable, JsonSerializable
*/ */
public function valid(): bool public function valid(): bool
{ {
$key = key($this->data); return key($this->data) !== null;
return null !== $key;
} }
/** /**

@ -3,7 +3,6 @@
declare(strict_types=1); declare(strict_types=1);
// This file is only here so that the PHP8 attribute for doesn't throw an error in files // 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 class ReturnTypeWillChange
{ {
} }

@ -1,7 +1,9 @@
<?php <?php
// require 'flight/Flight.php'; declare(strict_types=1);
require 'flight/autoload.php';
require_once 'flight/Flight.php';
// require 'flight/autoload.php';
Flight::route('/', function () { Flight::route('/', function () {
echo 'hello world!'; echo 'hello world!';

@ -9,35 +9,43 @@
</description> </description>
<arg name="colors" /> <arg name="colors" />
<arg name="tab-width" value="4" /> <arg name="tab-width" value="4" />
<rule ref="PSR12" />
<rule ref="PSR1"> <rule ref="PSR1">
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace" /> <exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace" />
</rule> </rule>
<rule ref="PSR12" />
<rule ref="Generic"> <rule ref="Generic">
<exclude name="Generic.Files.LineEndings.InvalidEOLChar" />
<exclude name="Generic.PHP.ClosingPHPTag.NotFound" /> <exclude name="Generic.PHP.ClosingPHPTag.NotFound" />
<exclude name="Generic.PHP.UpperCaseConstant.Found" /> <exclude name="Generic.PHP.UpperCaseConstant.Found" />
<exclude name="Generic.Arrays.DisallowShortArraySyntax.Found" /> <exclude name="Generic.Arrays.DisallowShortArraySyntax.Found" />
<exclude name="Generic.Files.EndFileNoNewline.Found" /> <exclude name="Generic.Files.EndFileNoNewline.Found" />
<exclude name="Generic.Files.LowercasedFilename.NotFound" />
<exclude name="Generic.Commenting.DocComment.TagValueIndent" /> <exclude name="Generic.Commenting.DocComment.TagValueIndent" />
<exclude name="Generic.Classes.OpeningBraceSameLine.BraceOnNewLine" /> <exclude name="Generic.Classes.OpeningBraceSameLine.BraceOnNewLine" />
<exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed" /> <exclude name="Generic.WhiteSpace.DisallowSpaceIndent.SpacesUsed" />
<exclude name="Generic.Files.LowercasedFilename.NotFound" /> <exclude name="Generic.Commenting.DocComment.ContentAfterOpen" />
<exclude name="Generic.Commenting.DocComment.ContentBeforeClose" />
<exclude name="Generic.Commenting.DocComment.MissingShort" />
<exclude name="Generic.Commenting.DocComment.SpacingBeforeShort" />
<exclude name="Generic.Formatting.NoSpaceAfterCast.SpaceFound" />
<exclude name="Generic.Functions.OpeningFunctionBraceKernighanRitchie.BraceOnNewLine" /> <exclude name="Generic.Functions.OpeningFunctionBraceKernighanRitchie.BraceOnNewLine" />
<exclude name="Generic.Formatting.MultipleStatementAlignment.NotSameWarning" /> <exclude name="Generic.Formatting.MultipleStatementAlignment.NotSameWarning" />
<exclude name="Generic.Formatting.SpaceAfterNot.Incorrect" />
<exclude name="Generic.Commenting.DocComment.SpacingBeforeShort" />
<exclude name="Generic.Commenting.DocComment.ContentAfterOpen" />
<exclude name="Generic.Functions.OpeningFunctionBraceBsdAllman.BraceOnSameLine" /> <exclude name="Generic.Functions.OpeningFunctionBraceBsdAllman.BraceOnSameLine" />
<exclude name="Generic.PHP.DisallowRequestSuperglobal.Found" /> <exclude name="Generic.PHP.DisallowRequestSuperglobal.Found" />
<exclude name="Generic.Commenting.DocComment.ContentBeforeClose" /> </rule>
<exclude name="Generic.ControlStructures.DisallowYodaConditions.Found" /> <rule ref="Generic.Files.LineLength">
<exclude name="Generic.Strings.UnnecessaryStringConcat.Found" /> <properties>
<exclude name="Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition" /> <property name="ignoreComments" value="true" />
<exclude name="Generic.Commenting.DocComment.MissingShort" /> </properties>
<exclude name="Generic.Commenting.DocComment.SpacingBeforeTags" /> </rule>
<exclude name="Generic.WhiteSpace.ArbitraryParenthesesSpacing.SpaceAfterOpen" /> <rule ref="Generic.Formatting.SpaceAfterNot">
<exclude name="Generic.WhiteSpace.ArbitraryParenthesesSpacing.SpaceBeforeClose" /> <properties>
<property name="spacing" value="0" />
</properties>
</rule>
<rule ref="Generic.WhiteSpace.ArbitraryParenthesesSpacing">
<properties>
<property name="ignoreNewlines" value="true" />
</properties>
</rule> </rule>
<file>flight/</file> <file>flight/</file>
<file>tests/</file> <file>tests/</file>

@ -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

@ -6,6 +6,7 @@ parameters:
level: 6 level: 6
excludePaths: excludePaths:
- vendor - vendor
- flight/util/ReturnTypeWillChange.php
paths: paths:
- flight - flight
- index.php - index.php

@ -11,10 +11,13 @@
stopOnFailure="true" stopOnFailure="true"
verbose="true" verbose="true"
colors="true"> colors="true">
<coverage processUncoveredFiles="true"> <coverage processUncoveredFiles="false">
<include> <include>
<directory suffix=".php">flight/</directory> <directory suffix=".php">flight/</directory>
</include> </include>
<exclude>
<file>flight/autoload.php</file>
</exclude>
</coverage> </coverage>
<testsuites> <testsuites>
<testsuite name="default"> <testsuite name="default">

@ -37,9 +37,7 @@ class DispatcherTest extends TestCase
public function testFunctionMapping(): void public function testFunctionMapping(): void
{ {
$this->dispatcher->set('map2', function (): string { $this->dispatcher->set('map2', fn (): string => 'hello');
return 'hello';
});
$this->assertSame('hello', $this->dispatcher->run('map2')); $this->assertSame('hello', $this->dispatcher->run('map2'));
} }
@ -61,6 +59,9 @@ class DispatcherTest extends TestCase
->set('map-event', $customFunction) ->set('map-event', $customFunction)
->set('map-event-2', $anotherFunction); ->set('map-event-2', $anotherFunction);
$this->assertTrue($this->dispatcher->has('map-event'));
$this->assertTrue($this->dispatcher->has('map-event-2'));
$this->dispatcher->clear(); $this->dispatcher->clear();
$this->assertFalse($this->dispatcher->has('map-event')); $this->assertFalse($this->dispatcher->has('map-event'));
@ -76,6 +77,9 @@ class DispatcherTest extends TestCase
->set('map-event', $customFunction) ->set('map-event', $customFunction)
->set('map-event-2', $anotherFunction); ->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->dispatcher->clear('map-event');
$this->assertFalse($this->dispatcher->has('map-event')); $this->assertFalse($this->dispatcher->has('map-event'));
@ -105,9 +109,7 @@ class DispatcherTest extends TestCase
public function testBeforeAndAfter(): void public function testBeforeAndAfter(): void
{ {
$this->dispatcher->set('hello', function (string $name): string { $this->dispatcher->set('hello', fn (string $name): string => "Hello, $name!");
return "Hello, $name!";
});
$this->dispatcher $this->dispatcher
->hook('hello', Dispatcher::FILTER_BEFORE, function (array &$params): void { ->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); $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 public function testInvalidCallback(): void
{ {
$this->expectException(Exception::class); $this->expectException(Exception::class);
@ -245,7 +266,7 @@ class DispatcherTest extends TestCase
public function testInvokeMethod(): void public function testInvokeMethod(): void
{ {
$class = new TesterClass('param1', 'param2', 'param3', 'param4', 'param5', 'param6'); $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); $this->assertSame('param1', $class->param2);
} }
@ -271,7 +292,7 @@ class DispatcherTest extends TestCase
public function testExecuteStringClassNoConstructArraySyntax(): void public function testExecuteStringClassNoConstructArraySyntax(): void
{ {
$result = $this->dispatcher->execute([ Hello::class, 'sayHi' ]); $result = $this->dispatcher->execute([Hello::class, 'sayHi']);
$this->assertSame('hello', $result); $this->assertSame('hello', $result);
} }
@ -298,7 +319,7 @@ class DispatcherTest extends TestCase
$engine = new Engine(); $engine = new Engine();
$engine->set('test_me_out', 'You got it boss!'); $engine->set('test_me_out', 'You got it boss!');
$this->dispatcher->setEngine($engine); $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); $this->assertSame('You got it boss!', $result);
} }
@ -306,6 +327,6 @@ class DispatcherTest extends TestCase
{ {
$this->expectException(TypeError::class); $this->expectException(TypeError::class);
$this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#'); $this->expectExceptionMessageMatches('#tests\\\\classes\\\\ContainerDefault::__construct\(\).+flight\\\\Engine, null given#');
$result = $this->dispatcher->execute([ ContainerDefault::class, 'testTheContainer' ]); $result = $this->dispatcher->execute([ContainerDefault::class, 'testTheContainer']);
} }
} }

@ -74,4 +74,22 @@ class DocExamplesTest extends TestCase
Flight::app()->handleException(new Exception('Error')); Flight::app()->handleException(new Exception('Error'));
$this->expectOutputString('Custom: 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 ');
}
} }

@ -11,6 +11,7 @@ use flight\Engine;
use flight\net\Request; use flight\net\Request;
use flight\net\Response; use flight\net\Response;
use flight\util\Collection; use flight\util\Collection;
use InvalidArgumentException;
use PDOException; use PDOException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use tests\classes\Container; use tests\classes\Container;
@ -86,8 +87,7 @@ class EngineTest extends TestCase
public function testHandleException() public function testHandleException()
{ {
$engine = new Engine(); $engine = new Engine();
$regex_message = preg_quote('<h1>500 Internal Server Error</h1><h3>thrown exception message (20)</h3>'); $this->expectOutputRegex('~\<h1\>500 Internal Server Error\</h1\>[\s\S]*\<h3\>thrown exception message \(20\)\</h3\>~');
$this->expectOutputRegex('~' . $regex_message . '~');
$engine->handleException(new Exception('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()); $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() public function testJsonP()
{ {
$engine = new Engine(); $engine = new Engine();
@ -572,6 +582,49 @@ class EngineTest extends TestCase
$this->expectOutputString('OK123after123'); $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() public function testMiddlewareClassAfterFailedCheck()
{ {
$middleware = new class { $middleware = new class {
@ -681,6 +734,14 @@ class EngineTest extends TestCase
$this->expectOutputString('before456before123OKafter123456after123'); $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() { public function testContainerDice() {
$engine = new Engine(); $engine = new Engine();
$dice = new \Dice\Dice(); $dice = new \Dice\Dice();
@ -751,8 +812,14 @@ class EngineTest extends TestCase
$engine->route('/container', Container::class.'->testThePdoWrapper'); $engine->route('/container', Container::class.'->testThePdoWrapper');
$engine->request()->url = '/container'; $engine->request()->url = '/container';
$this->expectException(ErrorException::class); // php 7.4 will throw a PDO exception, but php 8 will throw an ErrorException
$this->expectExceptionMessageMatches("/Passing null to parameter/"); 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(); $engine->start();
} }
@ -842,4 +909,47 @@ class EngineTest extends TestCase
$engine->start(); $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());
}
} }

@ -22,6 +22,7 @@ class FlightTest extends TestCase
$_REQUEST = []; $_REQUEST = [];
Flight::init(); Flight::init();
Flight::setEngine(new Engine()); Flight::setEngine(new Engine());
Flight::set('flight.views.path', __DIR__ . '/views');
} }
protected function tearDown(): void protected function tearDown(): void
@ -280,6 +281,30 @@ class FlightTest extends TestCase
} }
public function testStreamRoute() 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 { $response_mock = new class extends Response {
public function setRealHeader(string $header_string, bool $replace = true, int $response_code = 0): 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'); $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(<<<html
<div>Hi</div>
<div>Hi</div>
<input type="number" />
<input type="number" />
html);
Flight::render('myComponent', ['prop' => 'Hi']);
Flight::render('myComponent');
Flight::render('input', ['type' => 'number']);
Flight::render('input');
}
} }

@ -152,4 +152,17 @@ class LoaderTest extends TestCase
__DIR__ . '/classes' __DIR__ . '/classes'
], $loader->getDirectories()); ], $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());
}
} }

@ -21,7 +21,7 @@ class RedirectTest extends TestCase
public function getBaseUrl($base, $url) public function getBaseUrl($base, $url)
{ {
if ('/' !== $base && false === strpos($url, '://')) { if ($base !== '/' && strpos($url, '://') === false) {
$url = preg_replace('#/+#', '/', $base . '/' . $url); $url = preg_replace('#/+#', '/', $base . '/' . $url);
} }
@ -67,11 +67,7 @@ class RedirectTest extends TestCase
public function testBaseOverride() public function testBaseOverride()
{ {
$url = 'login'; $url = 'login';
if (null !== $this->app->get('flight.base_url')) { $base = $this->app->get('flight.base_url') ?? $this->app->request()->base;
$base = $this->app->get('flight.base_url');
} else {
$base = $this->app->request()->base;
}
self::assertEquals('/testdir/login', $this->getBaseUrl($base, $url)); self::assertEquals('/testdir/login', $this->getBaseUrl($base, $url));
} }

@ -255,4 +255,54 @@ class ResponseTest extends TestCase
$response->write('new', true); $response->write('new', true);
$this->assertEquals('new', $response->getBody()); $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);
}
} }

@ -336,12 +336,14 @@ class RouterTest extends TestCase
{ {
$this->router->map('GET /api/intune/hey', [$this, 'ok']); $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 = [ $query_params = [
'error=access_denied', 'error=access_denied',
'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'
. 'Correlation+ID%3a+362e3cb3-20ef-400b-904e-9983bd989184%0d%0a'
. 'Timestamp%3a+2022-09-08+09%3a58%3a12Z',
'error_uri=https%3a%2f%2flogin.microsoftonline.com%2ferror%3fcode%3d65004', 'error_uri=https%3a%2f%2flogin.microsoftonline.com%2ferror%3fcode%3d65004',
'admin_consent=True', 'admin_consent=True',
'state=x2EUE0fcSj#' 'state=x2EUE0fcSj#'
@ -629,6 +631,10 @@ class RouterTest extends TestCase
$this->router->rewind(); $this->router->rewind();
$result = $this->router->valid(); $result = $this->router->valid();
$this->assertTrue($result); $this->assertTrue($result);
$this->router->previous();
$result = $this->router->valid();
$this->assertFalse($result);
} }
public function testGetRootUrlByAlias() public function testGetRootUrlByAlias()

@ -152,4 +152,84 @@ class ViewTest extends TestCase
$viewMock::normalizePath('C:/xampp/htdocs/libs/Flight\core\index.php', '°') $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(<<<html
<div>Hi</div>
<div>Hi</div>
<input type="number" />
<input type="number" />
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(<<<html
<div>qux</div>
<div>bar</div>
html);
$this->view->render('myComponent', ['prop' => 'qux']);
$this->view->render('myComponent');
}
public static function renderDataProvider(): array
{
return [
[
<<<html
<div>Hi</div>
<div></div>
html,
['myComponent', ['prop' => 'Hi']],
'/^Undefined variable:? \$?prop$/'
],
[
<<<html
<input type="number" />
<input type="text" />
html,
['input', ['type' => 'number']],
'/^.*$/'
],
];
}
} }

@ -15,6 +15,11 @@ class ContainerDefault
$this->app = $engine; $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() public function testTheContainer()
{ {
return $this->app->get('test_me_out'); return $this->app->get('test_me_out');

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace tests\commands;
use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use flight\commands\ControllerCommand;
use PHPUnit\Framework\TestCase;
class ControllerCommandTest extends TestCase
{
protected static $in = __DIR__ . '/input.test';
protected static $ou = __DIR__ . '/output.test';
public function setUp(): void
{
file_put_contents(static::$in, '', LOCK_EX);
file_put_contents(static::$ou, '', LOCK_EX);
}
public function tearDown(): void
{
// Make sure we clean up after ourselves:
if (file_exists(static::$in)) {
unlink(static::$in);
}
if (file_exists(static::$ou)) {
unlink(static::$ou);
}
if (file_exists(__DIR__ . '/controllers/TestController.php')) {
unlink(__DIR__ . '/controllers/TestController.php');
}
if (file_exists(__DIR__ . '/controllers/')) {
rmdir(__DIR__ . '/controllers/');
}
}
protected function newApp(string $name, string $version = '')
{
$app = new Application($name, $version ?: '0.0.1', fn () => 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', '<?php class TestController {}');
$app->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');
}
}

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace tests\commands;
use Ahc\Cli\Application;
use Ahc\Cli\IO\Interactor;
use Flight;
use flight\commands\RouteCommand;
use flight\Engine;
use PHPUnit\Framework\TestCase;
class RouteCommandTest extends TestCase
{
protected static $in = __DIR__ . '/input.test';
protected static $ou = __DIR__ . '/output.test';
public function setUp(): void
{
file_put_contents(static::$in, '', LOCK_EX);
file_put_contents(static::$ou, '', LOCK_EX);
$_SERVER = [];
$_REQUEST = [];
Flight::init();
Flight::setEngine(new Engine());
}
public function tearDown(): void
{
// Make sure we clean up after ourselves:
if (file_exists(static::$in)) {
unlink(static::$in);
}
if (file_exists(static::$ou)) {
unlink(static::$ou);
}
if (file_exists(__DIR__ . '/index.php')) {
unlink(__DIR__ . '/index.php');
}
unset($_REQUEST);
unset($_SERVER);
Flight::clear();
}
protected function newApp(string $name, string $version = '')
{
$app = new Application($name, $version ?: '0.0.1', fn () => false);
return $app->io(new Interactor(static::$in, static::$ou));
}
protected function createIndexFile()
{
$index = <<<PHP
<?php
require __DIR__ . '/../../vendor/autoload.php';
Flight::route('GET /', function () {});
Flight::post('/post', function () {})->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)));
}
}

@ -1,9 +1,26 @@
#!/bin/bash #!/bin/bash
# Run all tests php_versions=("php7.4" "php8.0" "php8.1" "php8.2" "php8.3")
composer lint
composer beautify count=${#php_versions[@]}
composer phpcs
composer test-coverage
xdg-open http://localhost:8000 echo "Prettifying code first"
composer test-server 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

@ -116,6 +116,10 @@ Flight::route('/jsonp', function () {
echo "\n\n\n\n\n"; 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 // Test 10: Halt
Flight::route('/halt', function () { Flight::route('/halt', function () {
Flight::halt(400, 'Halt worked successfully'); Flight::halt(400, 'Halt worked successfully');
@ -200,6 +204,7 @@ echo '
<li><a href="/error">Error</a></li> <li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li> <li><a href="/json">JSON</a></li>
<li><a href="/jsonp?jsonp=myjson">JSONP</a></li> <li><a href="/jsonp?jsonp=myjson">JSONP</a></li>
<li><a href="/json-halt">JSON Halt</a></li>
<li><a href="/halt">Halt</a></li> <li><a href="/halt">Halt</a></li>
<li><a href="/redirect">Redirect</a></li> <li><a href="/redirect">Redirect</a></li>
</ul>'; </ul>';

@ -74,15 +74,18 @@ class LayoutMiddleware
<li><a href="/error">Error</a></li> <li><a href="/error">Error</a></li>
<li><a href="/json">JSON</a></li> <li><a href="/json">JSON</a></li>
<li><a href="/jsonp?jsonp=myjson">JSONP</a></li> <li><a href="/jsonp?jsonp=myjson">JSONP</a></li>
<li><a href="/json-halt">JSON Halt</a></li>
<li><a href="/halt">Halt</a></li> <li><a href="/halt">Halt</a></li>
<li><a href="/redirect">Redirect</a></li> <li><a href="/redirect">Redirect</a></li>
<li><a href="/streamResponse">Stream</a></li> <li><a href="/streamResponse">Stream Plain</a></li>
<li><a href="/streamWithHeaders">Stream Headers</a></li>
<li><a href="/overwrite">Overwrite Body</a></li> <li><a href="/overwrite">Overwrite Body</a></li>
<li><a href="/redirect/before%2Fafter">Slash in Param</a></li> <li><a href="/redirect/before%2Fafter">Slash in Param</a></li>
<li><a href="/わたしはひとです">UTF8 URL</a></li> <li><a href="/わたしはひとです">UTF8 URL</a></li>
<li><a href="/わたしはひとです/ええ">UTF8 URL w/ Param</a></li> <li><a href="/わたしはひとです/ええ">UTF8 URL w/ Param</a></li>
<li><a href="/dice">Dice Container</a></li> <li><a href="/dice">Dice Container</a></li>
<li><a href="/no-container">No Container Registered</a></li> <li><a href="/no-container">No Container Registered</a></li>
<li><a href="/Pascal_Snake_Case">Pascal_Snake_Case</a></li>
</ul> </ul>
HTML; HTML;
echo '<div id="container">'; echo '<div id="container">';

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
class Pascal_Snake_Case // phpcs:ignore
{
public function doILoad() // phpcs:ignore
{
echo 'Yes, I load!!!';
}
}

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use flight\core\Loader;
use flight\database\PdoWrapper; use flight\database\PdoWrapper;
use tests\classes\Container; use tests\classes\Container;
use tests\classes\ContainerDefault; use tests\classes\ContainerDefault;
@ -18,10 +19,8 @@ use tests\classes\ContainerDefault;
Flight::set('flight.content_length', false); Flight::set('flight.content_length', false);
Flight::set('flight.views.path', './'); Flight::set('flight.views.path', './');
Flight::set('flight.views.extension', '.phtml'); Flight::set('flight.views.extension', '.phtml');
//Flight::set('flight.v2.output_buffering', true); Loader::setV2ClassLoading(false);
Flight::path(__DIR__);
require_once 'LayoutMiddleware.php';
require_once 'OverwriteBodyMiddleware.php';
Flight::group('', function () { Flight::group('', function () {
@ -123,7 +122,19 @@ Flight::group('', function () {
ob_flush(); ob_flush();
} }
echo "is successful!!"; echo "is successful!!";
})->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 ]); })->streamWithHeaders(['Content-Type' => 'text/html', 'status' => 200 ]);
// Test 14: Overwrite the body with a middleware // Test 14: Overwrite the body with a middleware
Flight::route('/overwrite', function () { Flight::route('/overwrite', function () {
echo '<span id="infotext">Route text:</span> This route status is that it <span style="color:red; font-weight: bold;">failed</span>'; echo '<span id="infotext">Route text:</span> This route status is that it <span style="color:red; font-weight: bold;">failed</span>';
@ -147,6 +158,7 @@ Flight::group('', function () {
Flight::set('test_me_out', 'You got it boss!'); // used in /no-container route Flight::set('test_me_out', 'You got it boss!'); // used in /no-container route
Flight::route('/no-container', ContainerDefault::class . '->testUi'); Flight::route('/no-container', ContainerDefault::class . '->testUi');
Flight::route('/dice', Container::class . '->testThePdoWrapper'); Flight::route('/dice', Container::class . '->testThePdoWrapper');
Flight::route('/Pascal_Snake_Case', Pascal_Snake_Case::class . '->doILoad');
}, [ new LayoutMiddleware() ]); }, [ new LayoutMiddleware() ]);
// Test 9: JSON output (should not output any other html) // 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::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) { Flight::map('error', function (Throwable $e) {
echo sprintf( echo sprintf(
'<h1>500 Internal Server Error</h1>' . <<<HTML
'<h3>%s (%s)</h3>' . <h1>500 Internal Server Error</h1>
'<pre style="border: 2px solid red; padding: 21px; background: lightgray; font-weight: bold;">%s</pre>', <h3>%s (%s)</h3>
<pre style="border: 2px solid red; padding: 21px; background: lightgray; font-weight: bold;">%s</pre>
HTML,
$e->getMessage(), $e->getMessage(),
$e->getCode(), $e->getCode(),
str_replace(getenv('PWD'), '***CONFIDENTIAL***', $e->getTraceAsString()) str_replace(getenv('PWD'), '***CONFIDENTIAL***', $e->getTraceAsString())

@ -0,0 +1,7 @@
<?php
$type ??= 'text';
?>
<input type="<?= $type ?>" />

@ -0,0 +1 @@
<div><?= $prop ?></div>
Loading…
Cancel
Save